diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml index e655ef5497..209838a431 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -9,8 +9,8 @@ en: 9ca845902f1df3a2 zh: 41e3157a843a511e /agent-note-plugin-configuration-on-the-plugins-page/decision: - en: 7e59b06516a08622 - zh: 5ea0e46cbf5bd1f7 + en: ccee8068ea810fcb + zh: c87c4e6c44b33a67 /agent-note-plugin-configuration-on-the-plugins-page/consequences: en: d3d131d6c46bc32e zh: eb7c06f119dfbe8d diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md index cae789d99b..35aaefd028 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -10,7 +10,7 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta ## Decision -**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens its own page. The [plugin metadata decision](2026-09-28-bundle-and-plugin-display-metadata.md) owns that page's title and description; its slot key and technical identities do not change with the displayed text. +**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens its own page. The [plugin metadata decision](2026-09-18-localized-package-metadata.md) owns that page's title and description; its slot key and technical identities do not change with the displayed text. The page passes `view: 'page'` for each form. Official plugin cards and details also use `view: 'summary'`; a row's detail page uses it only when the plugin has no display description. Bundle configuration renders only `page`. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. Sidebar navigation and installation-request ownership follow the [sidebar management decision](2026-09-09-plugin-management-in-the-web-sidebar.md). diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md index bc8d9807c6..ccdaf5629a 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开它自己的页面。该页面的标题与描述遵循[插件元信息决策](2026-09-28-bundle-and-plugin-display-metadata.zh.md);slot 键和技术身份不随展示文本改变。 +**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开它自己的页面。该页面的标题与描述遵循[插件元信息决策](2026-09-18-localized-package-metadata.zh.md);slot 键和技术身份不随展示文本改变。 页面通过 `view: 'page'` 渲染每个表单。官方插件卡片与详情还使用 `view: 'summary'`;行详情页只在插件没有展示描述时使用它。组合包配置仅渲染 `page`。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。侧栏导航与安装请求归属遵循[侧栏管理决策](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml index 67f1cf843d..b3dcf3bc2e 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml @@ -9,8 +9,8 @@ en: 4022a58baf82e77d zh: 8439268ed4df9b69 /agent-note-plugin-owned-localized-display-metadata/decision: - en: f51995f39ca57f00 - zh: b2c558517e8e911f + en: 59c4324c707eab07 + zh: cf2a98f8a6aaf07a /agent-note-plugin-owned-localized-display-metadata/existing-decisions: en: a84f44d6a3179d47 zh: 8fb62f08bec5f75d @@ -18,8 +18,8 @@ en: abbdb1b8ab360380 zh: edfe6c06b1d67fd7 /agent-note-plugin-owned-localized-display-metadata/verification: - en: afd4d571d4f42baa - zh: 37b8e13d1a1a9760 + en: 6ca6d509ba8d1b55 + zh: 9ee94aa9c676233d /agent-note-plugin-owned-localized-display-metadata/consequences: - en: 7bb1420e544b533e - zh: c17b4c6190c9ec38 + en: 2a4547c9657038df + zh: 1a4bebd06d2b9e38 diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md index bcb98bc42e..1fee8eceb7 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md +++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md @@ -10,9 +10,7 @@ Plugin Manager needs readable titles and descriptions for installed bundles and ## Decision -The [bundle and plugin metadata decision](2026-09-28-bundle-and-plugin-display-metadata.md) partially supersedes this note: ordinary plugins use loaded exports. This note retains the bundle resource rules and their rationale. - -Each bundle supplies optional `meta.title` and `meta.description` strings in its `locale/.json` resources, starting with `locale/en.json`. These strings directly provide its display title and description. Other top-level locale content belongs to its existing consumers and is not included in metadata responses. +Each plugin supplies optional `meta.title` and `meta.description` strings in its `locale/.json` resources, starting with `locale/en.json`. These strings directly provide its display title and description. Other top-level locale content belongs to its existing consumers and is not included in metadata responses. ```json { @@ -23,7 +21,7 @@ Each bundle supplies optional `meta.title` and `meta.description` strings in its } ``` -The bundle package name owns the resource address. The resource reader accepts the following address forms; Plugin Manager calls it only for bundles. Node module resolution selects files through the applicable profile and package exports without evaluating plugin code: +The configured Cordis plugin name owns the resource address. Node module resolution selects files through the applicable profile and package exports without evaluating plugin code: | Plugin form | Locale resource | |---|---| @@ -33,24 +31,24 @@ The bundle package name owns the resource address. The resource reader accepts t Locale addresses are not derived from a resolved JavaScript filename or an owning package directory. File paths and file URLs, including Windows drive and UNC paths, skip resource resolution and return no metadata; a file address alone is not a metadata error. They supply neither sibling locale metadata nor a neighboring package manifest. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns directory examples, export declarations, publication entries, and author verification. -Language files for one bundle share a directory. The Host resolves `en.json`, discovers language filenames in that directory, and resolves each resource using the same plugin specifier and parent URL. The English resource anchors discovery, but any language may omit either display field. Language identifiers are case-insensitive and duplicates are rejected. Present locale display fields must be non-empty strings. +Language files for one plugin share a directory. The Host resolves `en.json`, discovers language filenames in that directory, and resolves each resource using the same plugin specifier and parent URL. The English resource anchors discovery, but any language may omit either display field. Language identifiers are case-insensitive and duplicates are rejected. Present locale display fields must be non-empty strings. -The Host reads installed bundle metadata without importing or activating their plugins. Remotes carry multilingual values or literal fallback text for the two display fields. The Client reuses its existing language selection and fallback chain at render time, without adding language registration, plugin-specific language defaults, or another language fallback policy. +The Host reads installed bundles and their declared plugin rows without importing or activating them. Remotes carry multilingual values or literal fallback text for the two display fields. The Client reuses its existing language selection and fallback chain at render time, without adding language registration, plugin-specific language defaults, or another language fallback policy. -Each field falls back independently before view-specific name formatting: +The [bundle and plugin display metadata decision](2026-09-28-bundle-and-plugin-display-metadata.md) supersedes ordinary-plugin package fallback and owns custom image scope. Locale resource identity, language selection, and non-activation remain governed here. -| Field | Existing locale fallback chain | Same-address package fallback | Final fallback | +| Field | Existing locale fallback chain | Bundle-only package fallback | Final fallback | |---|---|---|---| -| Title | `meta.title` | Non-empty `name` from `/package.json` | Complete configured Cordis plugin name | -| Description | `meta.description` | Non-empty `description` from `/package.json` | No package description | +| Title | `meta.title` | Non-empty `name` from `/package.json` | Complete configured Cordis plugin name | +| Description | `meta.description` | Non-empty `description` from `/package.json` | No description | -The package fallback obeys resource exports and belongs to the same plugin address; a subpath does not inherit its owning package's introduction. The Host supplies the package or final value as the English fallback for a field that has translations but no English value, preserving the existing Client locale API. Missing or unexported resources and missing fields use these fallbacks; invalid locale fields or malformed files report a diagnostic instead of silently falling back, while retaining management operations. +Bundle package fallback obeys resource exports; ordinary plugins do not read a manifest for display text. The Host supplies the applicable package or final value as the English fallback for a field that has translations but no English value, preserving the existing Client locale API. Missing or unexported resources and missing fields use these fallbacks; invalid locale fields or malformed files report a diagnostic instead of silently falling back, while retaining management operations. -Plugin Manager uses this resource metadata on installed bundle cards and details, preserving complete technical-name fallbacks. Ordinary component rows and Settings inventory use loaded exports as defined by the partially superseding decision. +Plugin Manager uses this metadata on installed bundle cards and details, component lists, and component configuration details, preserving complete technical-name fallbacks. Settings uses it in the plugin inventory, including preset plugins, but shortens literal package-name and module-name fallbacks by removing their npm scope and Cordis/DSH prefixes. Translated titles remain verbatim in both views; full module names, entry ids, search identities, and operation targets stay unchanged. The [Settings inventory README](../../../../packages/client/ui-settings-plugin-inventory/README.md#use-this-package) owns the prefix rules. A row configuration page uses its registered `summary` view only when the plugin has no display description. The bundle's raw `description` field remains available to Host and model consumers; it is not an additional UI fallback around resource exports. These rules preserve one displayed description while keeping configuration summaries available for plugins without one. -Disabled bundle metadata remains readable without activation. The Install view still uses registry information from `pnpm view`; it does not replace that information with locale metadata. Registry, Git, and tarball inspection before installation does not read remote package contents for translation. Model tool results retain their existing package information and exclude multilingual UI dictionaries. Session events remain unchanged. +Disabled plugins remain readable without activation. The Install view still uses registry information from `pnpm view`; it does not replace that information with locale metadata. Registry, Git, and tarball inspection before installation does not read remote package contents for translation. Model tool results retain their existing package information and exclude multilingual UI dictionaries. Session events remain unchanged. Built-in bundle introductions live in their exported language files. Beta markers, page grouping, configuration slot ownership, and enablement targets remain unchanged. Plugin introductions and component interaction copy retain separate owners. @@ -74,16 +72,16 @@ Built-in bundle introductions live in their exported language files. Beta marker ## Verification -- Bundles display local resource metadata; disabled bundles need no activation for reading. -- Language switching updates bundle titles and descriptions while keeping package identities and operation targets intact. +- Bundles, child plugins, and read-only inventory display local metadata; disabled packages need no activation for reading. +- Language switching updates titles and descriptions; Settings shortens only literal technical-name fallbacks, not translated titles, and keeps module identities and operation targets intact. - Direct metadata, unrelated locale content, absent resources, malformed JSON, missing display fields, and conflicting language identifiers have focused coverage. - Separate exports in one package and same-named packages under different parents retain independent metadata without executing plugin entries. - Windows drive paths, UNC paths, relative and absolute file paths, and file URLs return no metadata without invoking resource resolution. -- Missing locale resources and individual fields fall back independently to address-local package fields, then the complete module specifier without a description. +- Missing locale fields use complete module-name fallback and omit descriptions; only bundles also use address-local package fields. - Publication checks require readable resource exports and included language files, without duplicate ownership of built-in copy in the manager and plugins. - Management tools preserve existing model output, and pre-installation inspection adds no package-content lookup. - Focused tests and the complete `pnpm run build` succeed. ## Consequences -Omitted language files or exports can make metadata unavailable after publication, so verification covers both resolution and packaged files. Metadata errors remain per-bundle diagnostics without removing the manager's repair controls; file diagnostics retain their absolute paths. Display metadata is read per request without a metadata cache. Lookup retains the complete module specifier and its resolution parent; package-name-only caching cannot distinguish exported plugins or different installations. +Omitted language files or exports can make metadata unavailable after publication, so verification covers both resolution and packaged files. Metadata errors remain per-plugin diagnostics without removing the manager's repair controls; file diagnostics retain their absolute paths. Display metadata is read per request without a metadata cache. Lookup retains the complete module specifier and its resolution parent; package-name-only caching cannot distinguish exported plugins or different installations. diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md index 73c8b9b408..5875d1dcdb 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md @@ -10,9 +10,7 @@ Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题 ## 决策 -[组合包与插件元信息决策](2026-09-28-bundle-and-plugin-display-metadata.zh.md)部分替代本记录:普通插件使用已加载的导出。本记录保留组合包资源规则及其理由。 - -每个组合包从 `locale/en.json` 开始,在自己的 `locale/.json` 资源中提供可选的 `meta.title` 与 `meta.description` 字符串。这些字符串直接提供展示标题和描述。locale 的其他顶级内容仍归现有消费方所有,不进入元信息响应。 +每个插件从 `locale/en.json` 开始,在自己的 `locale/.json` 资源中提供可选的 `meta.title` 与 `meta.description` 字符串。这些字符串直接提供展示标题和描述。locale 的其他顶级内容仍归现有消费方所有,不进入元信息响应。 ```json { @@ -23,7 +21,7 @@ Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题 } ``` -资源地址归组合包的包名所有。资源读取器接受以下地址形式;Plugin Manager 仅为组合包调用它。Node 模块解析通过适用的 profile 和包 exports 选择文件,不执行插件代码: +资源地址归 Cordis 配置中的插件名所有。Node 模块解析通过适用的 profile 和包 exports 选择文件,不执行插件代码: | 插件形式 | locale 资源 | |---|---| @@ -33,24 +31,24 @@ Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题 locale 地址不从解析后的 JavaScript 文件名或所属包目录推导。文件路径与文件 URL(包括 Windows 盘符路径和 UNC 路径)跳过资源解析,返回无元信息;文件地址本身不构成元信息错误。它们既不提供同级 locale 元信息,也不提供旁边的包 manifest(元数据清单)。目录示例、exports 声明、发布条目与作者验证步骤由[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)维护。 -同一组合包的语言文件放在同一目录。Host 解析 `en.json`,从所在目录发现语言文件名,再用同一插件模块名与父 URL 解析各个资源。英文资源作为发现入口,但任何语言都可以省略任一展示字段。语言标识不区分大小写,重复标识拒绝。locale 中声明的展示字段必须是非空字符串。 +同一插件的语言文件放在同一目录。Host 解析 `en.json`,从所在目录发现语言文件名,再用同一插件模块名与父 URL 解析各个资源。英文资源作为发现入口,但任何语言都可以省略任一展示字段。语言标识不区分大小写,重复标识拒绝。locale 中声明的展示字段必须是非空字符串。 -Host 读取已安装组合包的元信息,不 import 或激活其中的插件。Remote 携带两个展示字段的多语言值或纯文本回退值。Client 在渲染时复用现有 locale 的语言选择与回退链,不增加语言注册、插件级默认语言或另一套语言回退规则。 +Host 读取已安装 bundle 及其声明的插件行,不 import 或激活它们。Remote 携带两个展示字段的多语言值或纯文本回退值。Client 在渲染时复用现有 locale 的语言选择与回退链,不增加语言注册、插件级默认语言或另一套语言回退规则。 -每个字段先独立回退,再按页面规则格式化技术名称: +[组合包与插件展示元信息决策](2026-09-28-bundle-and-plugin-display-metadata.zh.md)取代普通插件的包字段回退,并规定自定义图片的适用范围。locale 资源身份、语言选择和不激活读取仍由本文约束。 -| 字段 | 现有 locale 语言回退链 | 同地址包字段回退 | 最终回退 | +| 字段 | 现有 locale 语言回退链 | 仅组合包使用的包字段回退 | 最终回退 | |---|---|---|---| -| 标题 | `meta.title` | `<插件模块名>/package.json` 中非空的 `name` | Cordis 配置中的完整插件名 | -| 描述 | `meta.description` | `<插件模块名>/package.json` 中非空的 `description` | 不提供包描述 | +| 标题 | `meta.title` | `<组合包模块名>/package.json` 中非空的 `name` | Cordis 配置中的完整插件名 | +| 描述 | `meta.description` | `<组合包模块名>/package.json` 中非空的 `description` | 不显示描述 | -包字段回退遵守资源 exports,并且仍属于同一插件地址,子路径不继承所属包的介绍。字段有其他语言译文但没有英文值时,Host 将包字段或最终值放入英文回退位,保持现有 Client locale API 不变。资源不存在、未导出或缺少字段时使用上述回退;locale 字段无效或文件格式损坏时明确诊断,不静默回退,同时保留管理操作。 +组合包的包字段回退遵守资源 exports;普通插件不为展示文本读取清单。字段有其他语言译文但没有英文值时,Host 将适用的包字段或最终值放入英文回退位,保持现有 Client locale API 不变。资源不存在、未导出或缺少字段时使用上述回退;locale 字段无效或文件格式损坏时明确诊断,不静默回退,同时保留管理操作。 -插件管理页在已安装组合包的卡片和详情中使用这些资源元信息,保留完整技术名回退。普通组件行与设置清单使用部分替代决策所定义的已加载导出。 +插件管理页在已安装 bundle 的卡片和详情、组件列表、组件配置详情中使用这些元信息,保留完整技术名回退。设置中的插件清单也使用它,包括预设内插件,但会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀。两个页面都原样展示翻译标题;完整模块名、条目 id、搜索身份与操作目标保持不变。具体前缀规则由[设置插件清单 README](../../../../packages/client/ui-settings-plugin-inventory/README.zh.md#use-this-package)维护。 行配置页仅在插件没有展示描述时使用注册组件的 `summary` 视图。组合包原始的 `description` 字段仍供 Host 与模型消费方使用,不作为绕过资源 exports 的额外 UI 回退。这些规则只展示一份描述,同时保留无描述插件的配置摘要。 -禁用组合包的元信息不需要激活即可读取。Install 界面仍使用 `pnpm view` 返回的 registry 信息,不用 locale 元信息替换。安装前的 registry、Git 和 tarball 查询不为翻译读取远程包内容。模型工具结果保留现有包信息,不携带供 UI 使用的多语言字典。Session 事件不变。 +禁用插件不需要激活即可读取。Install 界面仍使用 `pnpm view` 返回的 registry 信息,不用 locale 元信息替换。安装前的 registry、Git 和 tarball 查询不为翻译读取远程包内容。模型工具结果保留现有包信息,不携带供 UI 使用的多语言字典。Session 事件不变。 内置 bundle 的介绍放在其导出的语言文件中。beta 标记、页面分组、配置槽归属与启停目标保持原样。插件介绍和组件自身的交互文案各自保持所有权。 @@ -74,16 +72,16 @@ Host 读取已安装组合包的元信息,不 import 或激活其中的插件 ## 验证 -- 组合包展示本地资源元信息;禁用组合包不需要激活即可读取。 -- 切换语言更新组合包标题和描述,保持包身份和操作目标不变。 +- bundle、子插件与只读 inventory 展示本地元信息;禁用包不需要激活即可读取。 +- 切换语言更新标题和描述;设置页仅缩短字面技术名回退,不缩短翻译标题,也不改变模块身份和操作目标。 - 直接元信息、无关 locale 内容、缺失资源、损坏 JSON、缺失展示字段及语言标识冲突均有针对性覆盖。 - 同包的不同 exports,以及不同父目录中的同名包,各自保持独立元信息,读取不执行插件入口。 - Windows 盘符路径、UNC 路径、相对与绝对文件路径及文件 URL 均不调用资源解析,直接返回无元信息。 -- locale 资源或个别字段缺失时,各字段独立回退到该插件地址的包字段,再回退到完整模块名且不显示描述。 +- locale 字段缺失时使用完整模块名回退并省略描述;只有组合包还使用其地址下的包字段。 - 发布检查要求资源 exports 可解析且包含语言文件,内置文案不在管理器和插件中双份维护。 - 管理工具保留现有模型输出,安装前查询不增加包内容探查。 - 针对性测试以及完整 `pnpm run build` 通过。 ## 影响 -遗漏语言文件或 exports 会使发布后的元信息不可用,因此验证覆盖解析与打包后的文件。元信息错误作为单组合包诊断呈现,不能让整个管理页失去修复入口;文件诊断保留绝对路径。展示元信息按请求读取,不设元信息缓存。查询保留完整模块名及其解析父地址;只按包名缓存无法区分导出插件或不同安装目录。 +遗漏语言文件或 exports 会使发布后的元信息不可用,因此验证覆盖解析与打包后的文件。元信息错误作为单插件诊断呈现,不能让整个管理页失去修复入口;文件诊断保留绝对路径。展示元信息按请求读取,不设元信息缓存。查询保留完整模块名及其解析父地址;只按包名缓存无法区分导出插件或不同安装目录。 diff --git a/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.i18n.yaml index cce4996007..928f1b331a 100644 --- a/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.i18n.yaml @@ -4,19 +4,19 @@ # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.md /agent-note-bundle-and-plugin-display-metadata: en: b3f0a481c93acf93 - zh: 57e0fa9916be587c + zh: 1f56d78dbb5ec156 /agent-note-bundle-and-plugin-display-metadata/problem: - en: ee48e0ca6ba41a43 - zh: 0379f5fc07cf0868 + en: 4d03f8587d64d8b6 + zh: 759778e57a45ea50 /agent-note-bundle-and-plugin-display-metadata/decision: - en: ba0a80d999293a66 - zh: 16fc59cdb0ed8402 + en: b9e443ec2926ef72 + zh: c8c9007b738e6acf /agent-note-bundle-and-plugin-display-metadata/alternatives-considered: - en: fd988b4e7087367e - zh: 3f95780616902ddc + en: e166b756964d5760 + zh: e39d867b51388884 /agent-note-bundle-and-plugin-display-metadata/verification: - en: 8697240eaab4f194 - zh: 63be8785d292217b + en: 73066506c74d39e0 + zh: a382ff7a4b380431 /agent-note-bundle-and-plugin-display-metadata/consequences: - en: 3c6dd2f752f9d68f - zh: 668e0332a4046bd7 + en: 3454aea4bac35a44 + zh: 8ddcc061c7969274 diff --git a/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.md b/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.md index 8559642dd9..4503be1aa4 100644 --- a/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.md +++ b/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.md @@ -6,36 +6,32 @@ English | [中文](2026-09-28-bundle-and-plugin-display-metadata.zh.md) ## Problem -A Cordis plugin can be a module namespace, function, or service class. It need not own an npm package. Reading a manifest for every plugin assigns package-level introductions and icons to entries that may represent unrelated functions in the same package. +One package can export several plugins. Its npm introduction and image identify the package, not every exported plugin. Display text must also remain available before activation and after a plugin fails to load. ## Decision -Bundles own package display resources and custom icons. Ordinary plugins provide optional title and description through the loaded plugin object's `meta` property: a named export for a function-plugin namespace, or a static property on a default-exported service class. Each field uses the existing literal-string or language-map representation with an English fallback. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns authoring examples. +Ordinary plugins reuse `meta.title` and `meta.description` in their exported locale JSON. They do not read package manifests for display text or custom images. The [localized metadata decision](2026-09-18-localized-package-metadata.md) retains ownership of locale resource identity, English discovery, per-field language fallback, diagnostics, and non-activation; this decision supersedes its ordinary-plugin package fallback. -Each Cordis fiber retains the unwrapped plugin supplied at registration. Loader exposes the current live root fiber's plugin, so HMR replacement and rollback expose the corresponding metadata. Inventory reads project only title and description from that object, including mounted preset entries. Reads never import plugins, resolve their locale resources, or open their manifests. An unloaded entry has no metadata; its module name remains available as the display fallback. Loaded entries waiting for dependencies or retaining an activation error can still supply text. Icons are omitted from ordinary plugin responses and rows. +Bundles use the same locale resources and can additionally read exported package-manifest text and an independent `./icon` resource. Node exports select both locale and icon paths, so authors can relocate assets without changing public resource addresses. An icon needs neither a top-level manifest field nor an exported `package.json`. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns the authoring format and image restrictions. -Bundle metadata remains readable while the bundle is disabled. Exported `locale/en.json` anchors language discovery; fields fall back independently to the accessible bundle manifest, then its complete name without a description. Resource resolution respects package exports and the caller's resolution base. File-addressed metadata requests do not probe neighboring resources. Invalid locale or icon files retain diagnostics and management controls. The icon reader returns a data URL for a supported image contained in the manifest directory, including after symlink resolution. +The manager passes the bundle directory it already resolved. Confinement uses that real package root, not the icon's directory or the locale directory: sibling asset directories are valid, while symlinks outside the package are not. A missing icon export is optional; an invalid target or unreadable selected file reports a diagnostic without discarding valid text. Ordinary plugin rows and their details use generic artwork. -The Client uses its existing language selection. Full module names, entry ids, search identities, and operation targets remain technical identities. Settings may shorten literal technical-name fallbacks; translated titles remain unchanged. Configuration summaries remain available when a plugin has no description. Registry installation previews, model-facing management output, and Session events do not receive multilingual UI dictionaries. - -This decision replaces the ordinary-plugin resource lookup in the [partially superseded metadata decision](2026-09-18-localized-package-metadata.md) and retains its bundle resource rules. Built-in bundle copy stays in exported locale files rather than a manager-owned dictionary. Publication checks validate bundle metadata resources and icons only. +No metadata lookup evaluates a plugin entry. Disabled and failed plugins retain locale text, and each full plugin specifier and resolution parent keeps its own resource identity. Publication checks validate ordinary plugin locales as well as bundle locales, selected image resources, and publication coverage. ## Alternatives considered -**Require one manifest per plugin.** Subpaths and local modules are valid Cordis plugins; package ownership is not part of their loading interface. +**Export a JavaScript `meta` value.** Reading it requires module evaluation and makes metadata depend on loading. Existing locale JSON already supports disabled plugins and independent subpath metadata. -**Import disabled plugins to read exports.** Importing executes module initialization. Opening or refreshing a management page must not execute an otherwise disabled plugin. +**Use package metadata for every plugin.** A package-wide introduction or image need not describe each plugin it exports. Ordinary plugins own their locale text; bundles own package-level display resources. -**Read sibling locale files or cache package metadata for plugin rows.** Compiled layout and package names do not identify individual plugin behavior. Retaining the actual loaded object preserves per-entry identity and avoids a separate cache invalidation policy. +**Keep a top-level `icon` path.** It adds a separate path declaration outside the existing resource-export mechanism. The fixed `./icon` address allows the same Node resolution and remapping as locale resources. -**Register display text during activation.** Pending plugins would lack metadata even though their module is already loaded. A passive exported value is available independently of service readiness. - -**Add a second language fallback policy or download packages for previews.** Existing locale selection covers display text, and registry previews need no additional package execution or downloads. +**Require a manifest export for icons.** Text fallback and image publication are independent. The already-resolved bundle root provides confinement without exposing another resource. ## Verification -Focused tests cover loaded namespace and service metadata, pending and failed entries, disable/re-enable replacement, HMR replacement and rollback, preset projection, profile-tree row identity, and absent manifest reads for ordinary rows. Bundle tests retain locale fallback and icon validation. Client and browser tests distinguish bundle artwork from ordinary plugin rows. Publication tests reject invalid bundle resources while ignoring unrelated plugin resources. +Focused reader and publication tests cover remapped and conditional targets, absent exports, invalid resources, size limits, confinement, and locale-only ordinary reads. Consumer and browser tests cover localized disabled rows, bundle images, generic ordinary artwork, and retained diagnostics. ## Consequences -Disabled or otherwise unloaded plugins display their module names even when their source exports metadata. Authors move ordinary plugin introductions from JSON resources to `meta`; only bundles can supply custom icons. +Bundle authors replace top-level `icon` declarations with `exports["./icon"]` and include the target in `files`. Ordinary authors who want package-description text on their plugin rows publish it in locale `meta.description`; otherwise the description is absent. The Host reads no extra JavaScript and needs no Cordis or Loader metadata API. Session logs and management-tool display exclusions remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.zh.md b/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.zh.md index 89e234c9f5..bb63c2dc29 100644 --- a/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 组合包与插件的展示元信息 +# Agent Note: 组合包与插件展示元信息 Status: implemented @@ -6,36 +6,32 @@ Status: implemented ## 问题 -Cordis 插件可以是模块命名空间、函数或服务类,不一定拥有 npm 包。为每个插件读取 manifest,会把包级介绍与图标分配给可能代表同包中不同功能的条目。 +一个包可以导出多个插件。它的 npm 介绍和图片标识整个包,不一定适用于每个导出的插件。展示文本也必须在激活前及插件加载失败后保持可用。 ## 决策 -组合包拥有包级展示资源与自定义图标。普通插件通过已加载插件对象的 `meta` 属性提供可选标题与描述:函数插件命名空间使用命名导出,默认导出的服务类使用静态属性。每个字段沿用字面字符串或包含英文回退值的语言映射形式。[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)维护编写示例。 +普通插件复用导出 locale JSON 中的 `meta.title` 和 `meta.description`,不为展示文本或自定义图片读取包清单。[本地化元信息决策](2026-09-18-localized-package-metadata.zh.md)继续约束 locale 资源身份、英文发现入口、逐字段语言回退、诊断与不激活读取;本文取代其中普通插件的包字段回退。 -每个 Cordis fiber 保留注册时传入的解包插件。Loader 暴露当前存活根 fiber 的插件,因此 HMR 替换和回滚会暴露对应的元信息。清单仅从该对象投影标题与描述,包括已挂载的预设条目。读取不会导入插件、解析其 locale 资源或打开其 manifest。未加载的条目没有元信息,模块名仍作为展示回退值。等待依赖或保留激活错误的已加载条目仍可提供文本。普通插件响应与行中不包含图标。 +组合包使用相同的 locale 资源,还可以读取导出的包清单文本和独立的 `./icon` 资源。Node exports 选择 locale 与图标路径,因此作者可以移动资源而不改变公开的资源地址。图标不需要顶层清单字段,也不要求导出 `package.json`。[包开发指南](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)负责说明作者格式与图片限制。 -组合包禁用时仍可读取其元信息。导出的 `locale/en.json` 作为语言发现入口;各字段独立回退到可访问的组合包 manifest,再回退到完整名称且不显示描述。资源解析遵循包 exports 与调用方的解析基准。文件地址形式的元信息请求不探查相邻资源。无效的 locale 或图标文件保留诊断与管理入口。图标读取器为 manifest 目录内受支持的图片返回 data URL,符号链接解析后也必须位于该目录内。 +管理器传入它已经解析出的组合包目录。路径限制使用包的真实根目录,而不是图标目录或 locale 目录:平行的资源目录有效,指向包外的符号链接无效。图标导出可以缺失;目标无效或选中的文件不可读时返回诊断,不丢弃有效文本。普通插件行及其详情使用通用插画。 -Client 使用现有的语言选择。完整模块名、条目 id、搜索身份与操作目标仍是技术身份。设置页可缩短字面的技术名回退值,翻译标题保持原样。插件没有描述时仍可使用配置摘要。注册表安装预览、面向模型的管理输出与 Session 事件不接收多语言 UI 字典。 - -本决策替代[部分被替代的元信息决策](2026-09-18-localized-package-metadata.zh.md)中的普通插件资源查询,并保留其组合包资源规则。内置组合包文案仍放在导出的 locale 文件中,不在管理器中维护词典。发布检查仅验证组合包的元信息资源与图标。 +元信息查询不执行插件入口。禁用或加载失败的插件仍保留 locale 文本,每个完整插件模块名及其解析父地址保持独立的资源身份。发布检查同时验证普通插件 locale、组合包 locale、选中的图片资源及发布文件覆盖。 ## 考虑过的替代方案 -**要求每个插件拥有 manifest。** 子路径和本地模块都是有效的 Cordis 插件;包归属不属于加载接口。 +**导出 JavaScript `meta` 值。** 读取它需要执行模块,使元信息依赖加载。现有 locale JSON 已支持禁用插件和独立的子路径元信息。 -**导入禁用插件以读取导出。** 导入会执行模块初始化。打开或刷新管理页不得执行原本禁用的插件。 +**为所有插件使用包元信息。** 包级介绍或图片不一定描述它导出的每个插件。普通插件拥有自己的 locale 文本,组合包拥有包级展示资源。 -**读取相邻 locale 文件或为插件行缓存包元信息。** 编译产物布局和包名无法标识单个插件的行为。保留实际加载的对象可维持各条目的身份,避免额外的缓存失效策略。 +**保留顶层 `icon` 路径。** 它在现有资源导出机制之外增加一处路径声明。固定的 `./icon` 地址与 locale 资源使用相同的 Node 解析和重定向方式。 -**激活时注册展示文本。** 等待依赖的插件虽然已加载模块,却无法提供元信息。被动的导出值不依赖服务就绪状态。 - -**增加另一套语言回退策略或为预览下载包。** 现有 locale 选择已覆盖展示文本,注册表预览不需要额外执行或下载包。 +**要求图标同时导出清单。** 文本回退和图片发布相互独立。已经解析的组合包根目录无需暴露另一项资源即可提供路径限制。 ## 验证 -针对性测试覆盖已加载命名空间与服务的元信息、等待及失败条目、禁用后重新启用的对象替换、HMR 替换与回滚、预设投影、profile 树中的行身份,以及普通行不读取 manifest。组合包测试保留 locale 回退与图标验证。Client 和浏览器测试区分组合包插画与普通插件行。发布测试拒绝无效组合包资源,同时忽略无关插件资源。 +针对性读取器和发布测试覆盖重定向与条件目标、缺失导出、无效资源、大小限制、路径限制,以及普通插件只读 locale 的行为。消费方和浏览器测试覆盖禁用行的本地化文本、组合包图片、普通插件通用插画和保留的诊断。 ## 影响 -禁用或其他未加载的插件显示模块名,即使其源码导出了元信息。作者需将普通插件介绍从 JSON 资源迁移到 `meta`;只有组合包可以提供自定义图标。 +组合包作者将顶层 `icon` 声明替换为 `exports["./icon"]`,并将目标加入 `files`。普通插件作者若希望插件行显示包描述文本,应将其发布到 locale `meta.description`;否则不显示描述。Host 不额外执行 JavaScript,也不需要 Cordis 或 Loader 元信息 API。Session 日志和管理工具的展示字段排除规则保持不变。 diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md index f7c4ff841c..5b28ff8aa8 100644 --- a/apps/web/tests/expected/plugin-manager/exports-en.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -1,28 +1,24 @@ - button "Back to plugins": Plugins - button "Uninstall @fixture/bundle": Uninstall -- switch "Enable @fixture/bundle" [checked] +- switch "Enable @fixture/bundle" - heading "@fixture/bundle" [level=3] - text: v0.0.1 - paragraph: - code: "@fixture/bundle" - paragraph: Registry description for the fixture bundle. - heading "Components" [level=4] -- text: 3 total · 3 running +- text: 3 total · 3 off - list: - listitem: - text: "@fixture/bundle" - code: fixture-row - - text: Running - - switch "Enable component @fixture/bundle" [checked] + - text: "Off" - listitem: - - text: File Search Search package introduction. + - text: File Search - code: fixture-search - code: "@fixture/bundle/search" - - text: Running - - switch "Enable component File Search" [checked] + - text: "Off" - listitem: - - text: Code Review Review workspace changes. + - text: "@fixture/bundle/review" - code: fixture-review - - code: "@fixture/bundle/review" - - text: Running - - switch "Enable component Code Review" [checked] + - text: "Off" diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md index 76a97dda57..dfca2b9bb4 100644 --- a/apps/web/tests/expected/plugin-manager/exports.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -1,28 +1,25 @@ - button "返回插件列表": 插件列表 - button "卸载 @fixture/bundle": 卸载 -- switch "启用 @fixture/bundle" [checked] +- switch "启用 @fixture/bundle" - heading "@fixture/bundle" [level=3] - text: v0.0.1 - paragraph: - code: "@fixture/bundle" - paragraph: Registry description for the fixture bundle. - heading "包含的组件" [level=4] -- text: 共 3 个 · 3 运行中 +- text: 共 3 个 · 3 已停用 - list: - listitem: - text: "@fixture/bundle" - code: fixture-row - - text: 运行中 - - switch "启用组件 @fixture/bundle" [checked] + - text: 已关闭 - listitem: - text: 文件搜索 搜索工作区中的文件。 - code: fixture-search - code: "@fixture/bundle/search" - - text: 运行中 - - switch "启用组件 文件搜索" [checked] + - text: 已关闭 - listitem: - text: 代码审查 审查工作区中的改动。 - code: fixture-review - code: "@fixture/bundle/review" - - text: 运行中 - - switch "启用组件 代码审查" [checked] + - text: 已关闭 diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 71aa6bbd5f..e801a36166 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -1,12 +1,12 @@ { "name": "@fixture/bundle", "version": "0.0.1", - "icon": "./icon.svg", "private": true, "description": "Registry description for the fixture bundle.", "type": "module", "main": "index.js", "exports": { + "./icon": "./icon.svg", ".": "./index.js", "./package.json": "./package.json", "./search": "./search.js", diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/review.js b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js index 8a239ad273..356cb5cf9a 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/review.js +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js @@ -1,6 +1,2 @@ -/** Inert review plugin with loaded display metadata for browser coverage. */ -export const meta = { - title: { en: 'Code Review', zh: '代码审查' }, - description: { en: 'Review workspace changes.', zh: '审查工作区中的改动。' }, -} +/** Inert review plugin for exported metadata browser coverage. */ export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.js b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js index 83e7cdf2db..c8858a7889 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/search.js +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js @@ -1,6 +1,2 @@ -/** Inert search plugin with loaded display metadata for browser coverage. */ -export const meta = { - title: { en: 'File Search', zh: '文件搜索' }, - description: { en: 'Search package introduction.', zh: '搜索工作区中的文件。' }, -} +/** Inert search plugin for exported metadata browser coverage. */ export function apply() {} diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 833e503869..9f622d30cc 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -10,7 +10,7 @@ import { chromium } from 'playwright' import { FiberState } from '@deepseek-ai/cordis' import { OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { join } from 'node:path' import { SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -61,8 +61,7 @@ describe('web e2e: plugin manager', () => { /** Change the UI language through Settings and close the dialog. */ async function setLanguage(language: 'en' | 'zh'): Promise { - const documentLanguage = language === 'zh' ? 'zh-CN' : language - if (await page.locator('html').getAttribute('lang') === documentLanguage) return + if (await page.locator('html').getAttribute('lang') === language) return const settings = language === 'en' ? '设置' : 'Settings' const source = language === 'en' ? '中文' : 'English' const target = language === 'en' ? 'English' : '中文' @@ -443,7 +442,7 @@ describe('web e2e: plugin manager', () => { // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) - expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle', exact: true }).count()).toBe(0) + expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0) await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 }) await panel.getByRole('button', { name: '返回插件列表' }).click() await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0) @@ -492,8 +491,9 @@ describe('web e2e: plugin manager', () => { await closeSettings() }) - it('decodes bundle manifest icons and uses generic artwork for ordinary rows', async () => { + it('decodes exported icons for disabled bundles and keeps ordinary plugin artwork generic', async () => { const panel = await openPluginsPanel() + onTestFinished(closeSettings) const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` const images: string[] = [] @@ -531,48 +531,44 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') expect(await panel.locator('[data-plugin-row="fixture-search"] img').count()).toBe(0) + expect(await panel.locator('[data-plugin-row="fixture-search"] svg').count()).toBeGreaterThan(0) images.push('Independent search row: generic artwork') await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) await panel.getByRole('button', { name: '返回插件列表' }).click() expect(tripwire.pageErrors).toEqual([]) }) - it('localizes loaded meta exports when a bundle is enabled', async () => { + it('localizes independent exports and falls back per field without activating the plugins', async () => { + onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() + const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' }) + const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' }) + await search.getByText('文件搜索', { exact: true }).waitFor() + await review.getByText('代码审查', { exact: true }).waitFor() + expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1) + expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1) + await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor() + expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false) + await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) try { - await panel.getByRole('switch', { name: '启用 @fixture/bundle', exact: true }).click() - await expect.poll(() => [...scaffold.ctx.loader.entries()] - .filter(entry => entry.options.name.startsWith('@fixture/bundle') && entry.fiber?.state === FiberState.ACTIVE).length).toBe(3) - await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() - const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' }) - const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' }) - await search.getByText('文件搜索', { exact: true }).waitFor() - await review.getByText('代码审查', { exact: true }).waitFor() - expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1) - expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1) - await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor() - expect([...scaffold.ctx.loader.entries()].filter(entry => entry.options.name.startsWith('@fixture/bundle'))).toHaveLength(3) - await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) await setLanguage('en') await search.getByText('File Search', { exact: true }).waitFor() - await review.getByText('Code Review', { exact: true }).waitFor() - await review.getByRole('switch', { name: 'Enable component Code Review', exact: true }).waitFor() - expect(await review.getByText('Review workspace changes.', { exact: true }).count()).toBe(1) - expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1) + expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0) + expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(0) expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0) await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle') } finally { - await scaffold.ctx.pluginManager.setBundleEnabled('@fixture/bundle', false) await setLanguage('zh') - await closeSettings() } await panel.getByRole('button', { name: '返回插件列表' }).click() expect(tripwire.pageErrors).toEqual([]) }) it('updates built-in names and descriptions when the UI language changes', async () => { + onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) const panel = await openPluginsPanel() await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() @@ -580,15 +576,15 @@ describe('web e2e: plugin manager', () => { expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1) const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' }) - await child.getByText('@deepseek-ai/dsh-experimental-tool-agent-team', { exact: true }).waitFor() - expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(0) + await child.getByText('团队工具', { exact: true }).waitFor() + expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) try { await setLanguage('en') await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1) - await child.getByText('@deepseek-ai/dsh-experimental-tool-agent-team', { exact: true }).waitFor() - expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(0) + await child.getByText('Team Tools', { exact: true }).waitFor() + expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor() expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1) @@ -600,7 +596,6 @@ describe('web e2e: plugin manager', () => { } } finally { await setLanguage('zh') - await closeSettings() } await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) @@ -676,7 +671,7 @@ describe('web e2e: plugin manager', () => { expect(running()).toBe(0) await panel.getByRole('button', { name: '查看 自动化任务', exact: true }).click() const rows = panel.locator('[data-plugin-rows]') - for (const title of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) { + for (const title of ['时间感知', '任务调度', '任务界面']) { await rows.locator('[data-plugin-row]', { hasText: title }).waitFor() } // A bundle that is off offers no row switches. @@ -686,7 +681,7 @@ describe('web e2e: plugin manager', () => { try { await expect.poll(running, { timeout: 20_000 }).toBe(3) await expect.poll(() => rows.locator('[data-plugin-row]', { hasText: '运行中' }).count(), { timeout: 20_000 }).toBe(3) - for (const title of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) { + for (const title of ['时间感知', '任务调度', '任务界面']) { await rows.getByRole('switch', { name: `启用组件 ${title}`, exact: true }).waitFor() } await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '自动化任务', exact: true }).waitFor() @@ -897,7 +892,7 @@ describe('web e2e: plugin manager', () => { await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE) // The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() - const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle', exact: true }) + const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }) await rowSwitch.waitFor({ timeout: 10_000 }) expect(await rowSwitch.getAttribute('aria-checked')).toBe('true') await rowSwitch.click() @@ -957,7 +952,7 @@ describe('web e2e: startup-applied plugin management', () => { // The pack's page lists its rows from their declarations, with no live entry to switch. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) - expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle', exact: true }).isDisabled()).toBe(true) + expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true) await panel.getByRole('button', { name: '返回插件列表' }).click() await toggle.click() diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index e0f015efa7..0c2feb9fbb 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -59,14 +59,16 @@ describe('web e2e: settings modal and General preferences', () => { it('opens the settings dialog, switches sections, and closes by every path', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell')) + onTestFinished(async () => { + const dialog = page.getByRole('dialog', { name: '设置' }) + if (await dialog.isVisible()) await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + }) const trigger = page.getByRole('button', { name: '设置', exact: true }) expect(await trigger.getAttribute('aria-haspopup')).toBe('dialog') expect(await trigger.getAttribute('aria-expanded')).toBe('false') await trigger.click() const dialog = page.getByRole('dialog', { name: '设置' }) - onTestFinished(async () => { - if (await dialog.isVisible()) await dialog.getByRole('button', { name: '关闭', exact: true }).click() - }) await dialog.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('true') // General is active by default; Permission, Language and Appearance are functional. diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 5f703210b0..9d1af2e2d4 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -20,9 +20,9 @@ /cookbook-adding-a-workspace-package/4-write-the-package-readme: en: 519b4adbef7b0b91 zh: b0b857ec6ee50ad7 -/cookbook-adding-a-workspace-package/5-add-optional-display-metadata: - en: a216b5f076e01aa1 - zh: 8b03469ddfe4d28f +/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: + en: 5a8003d2a65a17cc + zh: 6d4953baff59c53f /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 3140d87082..0e80b4c568 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 bundle display text: meta.title and meta.description + locale/en.json # optional display metadata: meta.title and meta.description 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 @@ -111,20 +111,9 @@ A package with no context effect or one consumer-owned path uses the audited `No -## 5. Add optional display metadata +## 5. Add optional plugin display metadata -Ordinary function plugins export `meta` from their plugin namespace; default-exported service classes use a static `meta` property. Each optional field is a literal string or a language map with an English fallback: - -```typescript -export const meta = { - title: { en: 'File Search', zh: '文件搜索' }, - description: 'Search workspace files.', -} -``` - -The inventory reads this property only from already-loaded plugins, including live preset compositions. It never imports a plugin for display. Unloaded plugins use their module names. Ordinary plugins have no custom icon and do not use package manifests or locale resources for display metadata. - -For a bundle, define its title and description in `locale/en.json`. Other language files, such as `locale/zh.json`, use the same fields: +For an npm package plugin, define its title and description in `locale/en.json`. Other language files, such as `locale/zh.json`, use the same fields: ```json { @@ -140,25 +129,32 @@ Merge these entries into `package.json`, retaining existing runtime exports and ```json { "exports": { - "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, "files": ["locale/*.json"] } ``` -Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-bundle diagnostic. +Keep language files together, with `locale/en.json` as the exported discovery entry. An exports mapping can redirect `./locale/*.json` to another directory; all language files for that plugin must share the selected directory. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. -Fields fall back independently before view-specific name formatting, using the existing locale language chain first: +Ordinary plugins read only these locale resources. Fields fall back independently through the existing locale language chain, then use the complete Cordis plugin name for a missing title and no description for a missing description. No JavaScript `meta` export is needed. -- Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name. -- Description: locale `meta.description` → `package.json.description` → no description. +Only bundles additionally use `name` and `description` from an accessible `/package.json` as text fallback. Export `./package.json` when the bundle needs that fallback; ordinary plugin display reads never probe it. -Export `/locale/en.json` for locale lookup; expose `/package.json` for package-field fallback or an icon declaration. +Only bundles can customize artwork. Export an `./icon` resource and include its target in `files`, retaining existing entries: -For an image on bundle cards and details, set top-level `"icon": "./icon.svg"` in that exported manifest and include the image in `files`. The path is relative to the bundle manifest's directory. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. Images need no separate export and 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; missing or undecodable images use the panel's default artwork. +```json +{ + "exports": { + "./icon": "./assets/logo.webp" + }, + "files": ["assets/logo.webp"] +} +``` -Bundle cards and details display this resource metadata even for disabled bundles. Component lists, plugin configuration details, and Settings use the loaded plugin's `meta` instead. Neither read activates plugins. +The icon uses Node exports resolution just like locale resources; no top-level `icon` field or `./package.json` export is required. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Node rejects invalid export targets; the Host also rejects paths and symlinks resolving outside the bundle directory. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the bundle. Invalid targets or unreadable files produce a metadata diagnostic while retaining valid text; absent exports or undecodable images use default artwork. Ordinary plugin rows and details always use generic artwork. + +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. @@ -169,7 +165,7 @@ Verify the result: 1. Run `pnpm run verify-package-meta` from the repository root to check fields, resource exports, and publication coverage. 2. Switch an installed plugin's applicable Plugin Manager and Settings entries between English and Chinese; check the title, description, per-field fallback, and Settings-only compact technical names. -See the [plugin metadata Agent Note](../../.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.md) for resource ownership and non-activation rationale. +See the [plugin metadata Agent Note](../../.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md) for resource ownership and non-activation rationale. ## 6. Verify diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index cd588b2e4f..e4822121e0 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 bundle display text: meta.title and meta.description + locale/en.json # optional display metadata: meta.title and meta.description 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 @@ -111,20 +111,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex -## 5. 添加可选的展示元信息 +## 5. 添加可选的插件展示元信息 -普通函数插件从插件命名空间导出 `meta`;默认导出的服务类使用静态 `meta` 属性。每个可选字段可以是字面字符串,也可以是包含英文回退值的语言映射: - -```typescript -export const meta = { - title: { en: 'File Search', zh: '文件搜索' }, - description: 'Search workspace files.', -} -``` - -清单仅从已加载的插件读取此属性,包括运行中的预设组合。它不会为了展示而导入插件。未加载的插件使用模块名。普通插件没有自定义图标,展示元信息也不使用包 manifest 或 locale 资源。 - -对于组合包,在 `locale/en.json` 中定义标题和描述。其他语言文件(如 `locale/zh.json`)使用相同字段: +对于 npm 包插件,在 `locale/en.json` 中定义标题和描述。其他语言文件(如 `locale/zh.json`)使用相同字段: ```json { @@ -140,25 +129,32 @@ export const meta = { ```json { "exports": { - "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, "files": ["locale/*.json"] } ``` -语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单组合包诊断。 +语言文件放在同一目录,以导出的 `locale/en.json` 作为发现入口。exports 映射可以将 `./locale/*.json` 重定向到其他目录;同一插件的所有语言文件必须共享选中的目录。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 -各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链: +普通插件只读取这些 locale 资源。各字段先按现有 locale 语言链独立回退;标题仍缺失时使用完整 Cordis 插件名,描述仍缺失时不显示描述。无需 JavaScript `meta` 导出。 -- 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。 -- 描述:locale `meta.description` → `package.json.description` → 不显示描述。 +只有组合包还会使用可访问的 `<组合包名>/package.json` 中的 `name` 和 `description` 作为文本回退。组合包需要该回退时导出 `./package.json`;普通插件的展示读取不会探查它。 -导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退或声明图标时,开放 `<包名>/package.json`。 +只有组合包可以定制插画。导出 `./icon` 资源并将目标加入 `files`,保留已有条目: -要在组合包卡片和详情显示图片,在该导出清单顶层设置 `"icon": "./icon.svg"`,并将图片加入 `files`。路径相对于组合包清单所在目录。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。图片不需要单独导出,且必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本;图片缺失或无法解码时使用面板的默认插画。 +```json +{ + "exports": { + "./icon": "./assets/logo.webp" + }, + "files": ["assets/logo.webp"] +} +``` -组合包卡片和详情展示这些资源元信息,包括已禁用的组合包。组件列表、插件配置详情与设置页则使用已加载插件的 `meta`。两种读取都不会激活插件。 +图标与 locale 资源一样使用 Node exports 解析;不需要顶层 `icon` 字段或 `./package.json` 导出。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。Node 拒绝无效的导出目标;Host 还会拒绝解析到组合包目录外的路径与符号链接。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活组合包。目标无效或文件不可读时,显示元信息诊断并保留有效文本;导出缺失或图片无法解码时使用默认插画。普通插件行及其详情始终使用通用插画。 + +已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。 @@ -169,7 +165,7 @@ Install 界面仍使用 `pnpm view` 返回的 npm registry 信息,不用 local 1. 在仓库根目录运行 `pnpm run verify-package-meta`,检查字段、资源 exports 和发布文件覆盖。 2. 对已安装插件,在适用的插件管理页与设置条目中切换中英文,检查标题、描述、逐字段回退,以及仅设置页使用的技术短名。 -资源归属与不激活插件的原因见[插件元信息 Agent Note](../../.agents/notes/implemented/architecture/2026-09-28-bundle-and-plugin-display-metadata.zh.md)。 +资源归属与不激活插件的原因见[插件元信息 Agent Note](../../.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md)。 ## 6. 验证 diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 0d1ae1f3f1..acc84e2f3a 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -9,8 +9,8 @@ en: b5b3510f4b6dd13a zh: a0c70471b6d779b5 /fiber/ctx-effect-execute-label: - en: 49e7a90726351489 - zh: 4db6bc88e42f016e + en: ef3418fd5ffbe86d + zh: c98935435da8bc7e /fiber/ctx-fiber: en: a10e38bf26b4ecfe zh: 7c91817984c95c99 @@ -39,26 +39,26 @@ en: 2a3b1d3bc37bf986 zh: 83bd257ee685d38b /fiber/the-fiber-class/fiber-name: - en: e4f962090c7ec2d8 - zh: 09d98a9d8cc42d24 + en: e5c2ecca13167a32 + zh: 284a461f0f1cd35b /fiber/the-fiber-class/fiber-assertactive: - en: 41f16c6770cb4a42 - zh: 78e5a409fcb8b51d + en: cceb503899634d09 + zh: a8381af1a23436f3 /fiber/the-fiber-class/fiber-effect-execute-label: - en: b5dcba51da82bb48 - zh: 102779d0a7033a83 + en: 55dcfacf295bbcf2 + zh: 6e3ec27bb8da5a3d /fiber/the-fiber-class/fiber-geteffects: - en: 79358f323a33e699 - zh: 054fa8af8dc8fdc0 + en: 4d739a9c186fd9c2 + zh: f80fd3b87e79b004 /fiber/the-fiber-class/fiber-await: - en: 2ae7b5df71654b1b - zh: 1e57e1c58e91e948 + en: 1d6258cbea61cd18 + zh: 2f10dda8918dadc5 /fiber/the-fiber-class/fiber-restart: - en: fa165e69390f028c - zh: 2a9a415d997a6a41 + en: 62d68dedb20b4fa9 + zh: 5a735ae0ff1266f4 /fiber/the-fiber-class/fiber-update-config-nosave: - en: 091e289d1d757d22 - zh: 09b8a8a34e5a807a + en: b57ce0f6260c2cb8 + zh: 3bfb6c5bc252a2c2 /fiber/effect: en: 007d5b214e8a8610 zh: d74c26fbb1b8aeb6 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 339ee481a3..3044301eff 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L417) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../vendor/cordis/src/fiber.ts#L338) +[Source](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../vendor/cordis/src/fiber.ts#L353) +[Source](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L417) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../vendor/cordis/src/fiber.ts#L570) +[Source](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L706) +[Source](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L720) +[Source](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../vendor/cordis/src/fiber.ts#L738) +[Source](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index 624c31f692..bee2f523f1 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L417) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -143,7 +143,7 @@ get name() 插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。 -[源码](../../vendor/cordis/src/fiber.ts#L338) +[源码](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -161,7 +161,7 @@ assertActive() **返回**:fiber 仍处于活动状态时不返回任何内容。 -[源码](../../vendor/cordis/src/fiber.ts#L353) +[源码](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -192,7 +192,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L417) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -209,7 +209,7 @@ getEffects() **返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。 -[源码](../../vendor/cordis/src/fiber.ts#L570) +[源码](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -227,7 +227,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L706) +[源码](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -245,7 +245,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L720) +[源码](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -273,7 +273,7 @@ update(config: any, noSave = false) **返回**无返回值;重启由 `internal/update` waterfall 执行。 -[源码](../../vendor/cordis/src/fiber.ts#L738) +[源码](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7b1cdb45a8..4385771ec8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b17084b493..b3edadf12e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -29,7 +29,7 @@ | `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | +| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index cc1d189e3f..cafcf643bf 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,5 +6,5 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: c49daf51d1468b37 - zh: f4964a074ca9002f + en: 80923699a3a99c2c + zh: d7b16fa2cf5d2bf3 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 0739170597..d23250e250 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. Its optional `meta` and each `BundleRowInfo.meta` contain locale display text or a metadata diagnostic; only the bundle's `meta` also uses package-field fallback and an exported icon. Clients select a language at render time. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 64ff36d412..5ea4a9494e 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,7 +10,7 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含 locale 展示文本或元信息诊断;只有组合包的 `meta` 还使用包字段回退和导出的图标。Client 在渲染时选择语言。 `InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 9cdb7bc924..59de3baedf 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -33,8 +33,8 @@ en: c4a301652b851ce9 zh: 784871e19cd9db0a /deepseek-ai-dsh-app-boot/use-this-package/reading-plugin-display-metadata: - en: 37cf7231d9871461 - zh: 73d154cdefcac14c + en: b2140bb40e2fec4d + zh: 8bfccd415ba671c9 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: en: 09890e218ab49c84 zh: e3d86597e7fb1adb diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 59dc735c4b..b11d2024a4 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -82,9 +82,9 @@ The projector preserves native omission behavior by checking literal defaults ag ### Reading plugin display metadata -`readLoadedPluginMeta(plugin)` projects only title and description from an already-loaded plugin's `meta` property. Inventory callers pass the Loader entry's live plugin object; unloaded entries return no metadata. This path does not read manifests or locale resources. Ordinary plugin metadata has no icon. +Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read exported locale display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. Ordinary plugins do not probe package manifests or icons. File paths and file URLs return no metadata without resolving resources. Malformed locale metadata returns an `error` diagnostic; valid results retain translations for Client-side language selection. -Use `readPluginMeta(specifier, parentURL)` for installed bundle display text without importing or activating its plugins. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads `package.json.icon` as an image data URL, even when locale text is complete; an icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. +For bundles, pass `{ bundleDirectory }` as the third argument to `readPluginMeta`, using the already-resolved package directory. This enables exported `package.json` text fallback and the independent `./icon` resource. Icon resolution does not require a manifest export; the real package directory constrains the selected image path. An icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 9a90af3685..e01fe21faf 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -82,9 +82,9 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek- ### 读取插件展示元信息 -`readLoadedPluginMeta(plugin)` 仅从已加载插件的 `meta` 属性投影标题与描述。清单调用方传入 Loader 条目的当前插件对象;未加载条目返回无元信息。此路径不读取 manifest 或 locale 资源。普通插件元信息不包含图标。 +使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取导出的 locale 展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。普通插件不探查包清单或图标。文件路径与文件 URL 不解析资源,直接返回无元信息。格式错误的 locale 元信息返回 `error` 诊断;有效结果保留翻译,由 Client 选择语言。 -使用 `readPluginMeta(specifier, parentURL)` 读取已安装组合包的展示文本,无需导入或激活其中的插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会将 `package.json.icon` 加载为图片 data URL;图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 +对于组合包,将已解析的包目录作为 `{ bundleDirectory }` 传给 `readPluginMeta` 的第三个参数。这会启用导出的 `package.json` 文本回退和独立的 `./icon` 资源。图标解析不要求导出清单;选中的图片路径受包的真实目录约束。图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 ### 启动与重载失败 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 07a4cac857..188cb33460 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -27,7 +27,6 @@ export { import { prepareProfilePatches } from './compatibility-preflight.ts' export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts' export { readPluginMeta } from './package-meta.ts' -export { readLoadedPluginMeta, type LoadedPluginMeta } from './loaded-plugin-meta.ts' export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts' export { isNativeConfigSchema } from './config-schema/native.ts' diff --git a/packages/boot/app-boot/src/loaded-plugin-meta.ts b/packages/boot/app-boot/src/loaded-plugin-meta.ts deleted file mode 100644 index 6e373b4b06..0000000000 --- a/packages/boot/app-boot/src/loaded-plugin-meta.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Display text published directly by an already-loaded plugin. */ -import type { Plugin } from '@deepseek-ai/cordis' -import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' - -/** Optional `meta` export, or static class property, for ordinary plugin display text. */ -export type LoadedPluginMeta = Pick - -/** - * Project a loaded plugin's display text without resolving package resources. - * @param plugin - Loader-unwrapped plugin, absent when the entry has no live plugin. - * @returns Explicit title and description only, when the plugin publishes metadata. - */ -export function readLoadedPluginMeta(plugin: Plugin | undefined): LoadedPluginMeta | undefined { - const meta = (plugin as (Plugin & { meta?: LoadedPluginMeta }) | undefined)?.meta - if (meta === undefined) return undefined - return { - ...meta.title === undefined ? {} : { title: meta.title }, - ...meta.description === undefined ? {} : { description: meta.description }, - } -} diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index 131082ce8a..3ac75985f8 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -1,7 +1,7 @@ /** Read plugin display text and icons through exported resources without evaluating plugin code. */ import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { dirname, extname, isAbsolute, relative, resolve, sep, win32 } from 'node:path' +import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' @@ -18,25 +18,28 @@ const ICON_MEDIA_TYPES = new Map([ ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ]) -function iconOf(value: unknown, manifestPath: string): string | undefined { - const icon = textOf(value, `${manifestPath}: icon`) - if (icon === undefined) return undefined - if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) { - throw new Error(`${manifestPath}: icon must be a relative file path`) +function iconOf(specifier: string, parentURL: string, bundleDirectory: string): string | undefined { + if (readObject(join(bundleDirectory, 'package.json')).exports == null) return undefined + let target: string + try { + target = resolvePluginResource(`${specifier}/icon`, parentURL) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') return undefined + throw error } - const mediaType = ICON_MEDIA_TYPES.get(extname(icon).toLowerCase()) - if (mediaType === undefined) throw new Error(`${manifestPath}: icon must be SVG, PNG, JPEG, or WebP`) - const directory = realpathSync(dirname(manifestPath)) - const file = realpathSync(resolve(directory, icon)) + const mediaType = ICON_MEDIA_TYPES.get(extname(target).toLowerCase()) + if (mediaType === undefined) throw new Error(`${target}: icon must be SVG, PNG, JPEG, or WebP`) + const directory = realpathSync(bundleDirectory) + const file = realpathSync(target) const local = relative(directory, file) if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { - throw new Error(`${manifestPath}: icon must remain inside its manifest directory`) + throw new Error(`${target}: icon must remain inside its bundle directory`) } const stat = statSync(file) - if (!stat.isFile()) throw new Error(`${manifestPath}: icon must be a regular file`) - if (stat.size > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + if (!stat.isFile()) throw new Error(`${target}: icon must be a regular file`) + if (stat.size > MAX_ICON_BYTES) throw new Error(`${target}: icon exceeds 256 KiB`) const bytes = readFileSync(file) - if (bytes.length > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + if (bytes.length > MAX_ICON_BYTES) throw new Error(`${target}: icon exceeds 256 KiB`) return `data:${mediaType};base64,${bytes.toString('base64')}` } @@ -133,24 +136,28 @@ function localizedText( } /** - * Read localized display text and the icon declared in a plugin's exported package.json. - * Icons are manifest-relative SVG, PNG, JPEG, or WebP files of at most 256 KiB, - * contained in the manifest directory after realpath resolution. Icon failures retain display text. + * Read plugin locale JSON display text without evaluating JavaScript entries. + * Bundle options enable exported package.json text fallback and an independent ./icon resource. + * Bundle icons are SVG, PNG, JPEG, or WebP files of at most 256 KiB, contained in + * the supplied bundle directory after realpath resolution. Icon failures retain display text. * Non-package specifiers are skipped without invoking the resource resolver. * Language files share the directory containing the resolved English resource; * each file is resolved through the complete plugin specifier before reading. - * Missing fields use the same address's package.json name/description. Translation - * maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions. + * Translation maps retain an English fallback, ultimately the full module specifier + * for titles and empty for descriptions. Ordinary plugins never probe package.json or icons. * @param specifier - configured plugin module name, including any package subpath. * @param parentURL - owning Loader tree's module-resolution base. - * @returns display fields and any icon diagnostic, or undefined for non-package specifiers or absent metadata. + * @param options - known bundle root; omit for ordinary plugin locale-only metadata. + * @returns display fields and any resource diagnostic, or undefined for non-package specifiers or absent metadata. */ -export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { +export function readPluginMeta( + specifier: string, parentURL: string, options?: { bundleDirectory: string }, +): PluginLocalizedMeta | undefined { if (barePackageName(specifier) === undefined) return undefined try { const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) const dictionaries = englishPath === undefined ? new Map() : dictionariesOf(englishPath, specifier, parentURL) - const manifestPath = optionalResourcePath(`${specifier}/package.json`, parentURL) + const manifestPath = options === undefined ? undefined : optionalResourcePath(`${specifier}/package.json`, parentURL) const manifest = manifestPath === undefined ? undefined : readObject(manifestPath) const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier) const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '') @@ -160,7 +167,7 @@ export function readPluginMeta(specifier: string, parentURL: string): PluginLoca } let icon: string | undefined try { - icon = manifestPath === undefined ? undefined : iconOf(manifest?.icon, manifestPath) + icon = options === undefined ? undefined : iconOf(specifier, parentURL, options.bundleDirectory) } catch (error) { return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` } } diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts index 9e24b10573..5523b51eaf 100644 --- a/packages/boot/app-boot/src/profile-resolution/service.ts +++ b/packages/boot/app-boot/src/profile-resolution/service.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import { Service, type Context } from '@deepseek-ai/cordis' +import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import { barePackageName, installRuntimeInterception, @@ -11,6 +12,7 @@ import { type RuntimeInterception, } from './resolver.ts' import type { RuntimeResolution } from '../profile.ts' +import { readPluginMeta } from '../package-meta.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -103,6 +105,15 @@ export class PluginPackages extends Service { return this.packages.get(key) } + /** + * Read display metadata without loading or activating the target plugin. + * @param specifier - configured package module, including package subpaths. + * @param parentURL - owning Loader tree's resolution base. + * @returns local display metadata or its diagnostic; undefined for non-package requests or absent metadata. + */ + metaOf(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { + return readPluginMeta(specifier, parentURL) + } } function packageDirFromParent(name: string, parentURL: string): string | undefined { diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts index 3f6a170777..3fbd420a80 100644 --- a/packages/boot/app-boot/tests/package-meta.spec.ts +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -105,32 +105,44 @@ describe('plugin locale display metadata', () => { }) }) + it('uses only locales for ordinary plugins even when manifest and icon exports are broken', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './broken.json', './icon': './missing.svg' }) + file(join(dir, 'broken.json'), '{') + dictionary('en', { meta: { title: 'Ordinary' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Ordinary' } }) + }) + + it.each([undefined, null])('keeps a bundle with exports=%j manageable without an icon', (exports) => { + file(join(dir, 'package.json'), JSON.stringify({ name: 'localized', description: 'Plain bundle', type: 'module', exports })) + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: 'localized', description: 'Plain bundle' }) + }) + it('falls back to package fields when locale resources are absent', () => { manifest({ '.': './index.js', './package.json': './package.json' }, { description: 'Package introduction' }) - expect(readPluginMeta('localized', parentURL)).toEqual({ title: 'localized', description: 'Package introduction' }) + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: 'localized', description: 'Package introduction' }) }) it('falls back per field without replacing available locale translations', () => { manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { description: 'Package introduction' }) dictionary('en', { meta: { title: 'English title' } }) dictionary('zh', { meta: { description: '中文介绍' } }) - expect(readPluginMeta('localized', parentURL)).toEqual({ + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: { en: 'English title' }, description: { en: 'Package introduction', zh: '中文介绍' }, }) dictionary('en', { meta: { description: 'English introduction' } }) dictionary('zh', { meta: { title: '中文标题' } }) - expect(readPluginMeta('localized', parentURL)).toEqual({ + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: { en: 'localized', zh: '中文标题' }, description: { en: 'English introduction' }, }) dictionary('en', {}) dictionary('zh', {}) - expect(readPluginMeta('localized', parentURL)).toEqual({ title: 'localized', description: 'Package introduction' }) + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: 'localized', description: 'Package introduction' }) }) it('retains non-English descriptions with an empty final English fallback', () => { dictionary('en', { meta: { title: 'Plugin' } }) dictionary('zh', { meta: { description: '中文介绍' } }) - expect(readPluginMeta('localized', parentURL)).toEqual({ + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: { en: 'Plugin' }, description: { en: '', zh: '中文介绍' }, }) }) @@ -141,33 +153,33 @@ describe('plugin locale display metadata', () => { './search/package.json': './search-manifest.json', }, { description: 'Whole package' }) file(join(dir, 'search-manifest.json'), JSON.stringify({ name: 'Search plugin', description: 'Search introduction' })) - expect(readPluginMeta('localized/search', parentURL)).toEqual({ title: 'Search plugin', description: 'Search introduction' }) - expect(readPluginMeta('localized/review', parentURL)).toBeUndefined() + expect(readPluginMeta('localized/search', parentURL, { bundleDirectory: dir })).toEqual({ title: 'Search plugin', description: 'Search introduction' }) + expect(readPluginMeta('localized/review', parentURL, { bundleDirectory: dir })).toBeUndefined() }) it.each([{}, { name: '', description: ' ' }, { name: false, description: null }])('ignores unavailable package text: %j', (fields) => { manifest({ './feature/package.json': './feature.json' }) file(join(dir, 'feature.json'), JSON.stringify(fields)) - expect(readPluginMeta('localized/feature', parentURL)).toBeUndefined() + expect(readPluginMeta('localized/feature', parentURL, { bundleDirectory: dir })).toBeUndefined() }) it('reports malformed fallback files', () => { manifest({ './feature/package.json': './feature.json' }) file(join(dir, 'feature.json'), '{') - expect(readPluginMeta('localized/feature', parentURL)?.error).toContain('feature.json') + expect(readPluginMeta('localized/feature', parentURL, { bundleDirectory: dir })?.error).toContain('feature.json') }) it('does not hide invalid locale fields behind package text', () => { manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { description: 'Package description' }) dictionary('en', { meta: { title: false } }) - expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string') + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })?.error).toContain('meta.title must be a non-empty string') }) - it('reads the manifest icon even when both English fields are supplied', () => { - manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon: './art/team.svg' }) + it('reads the exported bundle icon even when both English fields are supplied', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json', './icon': './art/team.svg' }) file(join(dir, 'art', 'team.svg'), '') dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } }) - expect(readPluginMeta('localized', parentURL)).toEqual({ + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: { en: 'Plugin' }, description: { en: 'Locale introduction' }, icon: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, }) @@ -177,67 +189,54 @@ describe('plugin locale display metadata', () => { ['svg', 'image/svg+xml'], ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['webp', 'image/webp'], ['SVG', 'image/svg+xml'], ])('encodes a package-local %s icon without executing the plugin', (extension, mediaType) => { - manifest({ '.': './index.js', './package.json': './package.json' }, { icon: `./icon.${extension}` }) + manifest({ '.': './index.js', './package.json': './package.json', './icon': `./icon.${extension}` }) const bytes = Buffer.from([0, 1, 127, 128, 255]) writeFileSync(join(dir, `icon.${extension}`), bytes) - expect(readPluginMeta('localized', parentURL)).toEqual({ + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ title: 'localized', icon: `data:${mediaType};base64,${bytes.toString('base64')}`, }) }) - it('reads an icon-only subexport relative to its own manifest without inheriting the package icon', () => { - manifest({ './package.json': './package.json', './search/package.json': './search/manifest.json' }, { icon: './root.png' }) - file(join(dir, 'root.png'), 'root') - file(join(dir, 'search', 'manifest.json'), JSON.stringify({ icon: './icon.webp' })) - file(join(dir, 'search', 'icon.webp'), 'search') - expect(readPluginMeta('localized/search', parentURL)).toEqual({ - icon: `data:image/webp;base64,${Buffer.from('search').toString('base64')}`, + it.each(['./assets/logo.webp', { import: './assets/logo.webp', default: './missing.gif' }])('reads an independent icon using the Node-selected export %j', (icon) => { + manifest({ './icon': icon }) + file(join(dir, 'assets', 'logo.webp'), 'image') + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })).toEqual({ + icon: `data:image/webp;base64,${Buffer.from('image').toString('base64')}`, }) - expect(readPluginMeta('localized/review', parentURL)).toBeUndefined() }) - it.each([null, false, 1, {}, [], '', ' '])('retains localized text while rejecting malformed icon %j', (icon) => { - manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon }) - dictionary('en', { meta: { title: 'Plugin', description: 'Introduction' } }) - dictionary('zh', { meta: { title: '插件' } }) - const { error, ...meta } = readPluginMeta('localized', parentURL)! - expect(meta).toEqual({ title: { en: 'Plugin', zh: '插件' }, description: { en: 'Introduction' } }) - expect(error).toContain('icon must be a non-empty string') + it.each([false, 1, '', '/tmp/icon.svg', '../icon.svg', 'https://example.test/icon.svg'])('reports invalid exported icon target %j while retaining text', (icon) => { + manifest({ './locale/*.json': './locale/*.json', './icon': icon }) + dictionary('en', { meta: { title: 'Plugin' } }) + const meta = readPluginMeta('localized', parentURL, { bundleDirectory: dir }) + expect(meta?.title).toEqual({ en: 'Plugin' }) + expect(meta?.error).toBeDefined() }) - it.each(['/tmp/icon.svg', 'C:\\icon.png', 'C:icon.png', '\\\\server\\share\\icon.png', 'https://example.test/icon.svg', 'data:image/svg+xml,'])('rejects non-relative icon %s', (icon) => { - manifest({ './package.json': './package.json' }, { icon, description: 'Introduction' }) - const { error, ...meta } = readPluginMeta('localized', parentURL)! - expect(meta).toEqual({ title: 'localized', description: 'Introduction' }) - expect(error).toContain('icon must be a relative file path') + it.each(['icon.gif', 'icon.html', 'icon'])('rejects unsupported icon file %s', (icon) => { + manifest({ './package.json': './package.json', './icon': `./${icon}` }) + file(join(dir, icon), 'unsupported') + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })?.error).toContain('icon must be SVG, PNG, JPEG, or WebP') }) - it.each(['icon.gif', 'icon.html', 'icon', 'icon.svg?query'])('rejects unsupported icon file %s', (icon) => { - manifest({ './package.json': './package.json' }, { icon }) - expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be SVG, PNG, JPEG, or WebP') - }) - - it('reports missing files, directories, and paths outside the manifest directory', () => { - manifest({ './package.json': './package.json' }, { icon: './missing.svg' }) - expect(readPluginMeta('localized', parentURL)?.error).toContain('missing.svg') - mkdirSync(join(dir, 'missing.svg')) - expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be a regular file') - manifest({ './package.json': './package.json' }, { icon: '../localized-other/icon.svg' }) - file(join(root, 'node_modules', 'localized-other', 'icon.svg'), 'outside') - expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + it.each(['missing', 'directory'])('reports a %s icon target', (kind) => { + manifest({ './icon': './missing.svg' }) + if (kind === 'directory') mkdirSync(join(dir, 'missing.svg')) + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })?.error) + .toContain(kind === 'directory' ? 'Directory import' : 'Cannot find module') }) it('accepts an icon at the byte limit and rejects one byte more', () => { - manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + manifest({ './package.json': './package.json', './icon': './icon.png' }) const bytes = Buffer.alloc(256 * 1024) writeFileSync(join(dir, 'icon.png'), bytes) - expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/png;base64,${bytes.toString('base64')}`) + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })?.icon).toBe(`data:image/png;base64,${bytes.toString('base64')}`) writeFileSync(join(dir, 'icon.png'), Buffer.alloc(bytes.length + 1)) - expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })?.error).toContain('icon exceeds 256 KiB') }) - it('rejects an icon that grows beyond the limit after its size check', () => { - manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + it.each(['growth', 'directory'])('rejects an icon changed after resolution: %s', (change) => { + manifest({ './package.json': './package.json', './icon': './icon.png' }) const icon = join(dir, 'icon.png') file(icon, 'small') const original = fs.statSync @@ -245,35 +244,35 @@ describe('plugin locale display metadata', () => { try { stat.mockImplementation(new Proxy(original, { apply(target, receiver: unknown, args: unknown[]): unknown { + const ownIcon = String(args[0]).endsWith(join('localized', 'icon.png')) + if (ownIcon && change === 'directory') { + unlinkSync(icon) + mkdirSync(icon) + } const result: unknown = Reflect.apply(target, receiver, args) - if (String(args[0]).endsWith(join('localized', 'icon.png'))) writeFileSync(icon, Buffer.alloc(256 * 1024 + 1)) + if (ownIcon && change === 'growth') writeFileSync(icon, Buffer.alloc(256 * 1024 + 1)) return result }, })) syncBuiltinESMExports() - expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + expect(readPluginMeta('localized', parentURL, { bundleDirectory: dir })?.error) + .toContain(change === 'growth' ? 'icon exceeds 256 KiB' : 'icon must be a regular file') } finally { stat.mockRestore() syncBuiltinESMExports() } }) - it('rejects icons reached through an external directory link and accepts internal links', () => { - manifest({ './package.json': './package.json' }, { icon: './art/icon.svg' }) - const outside = join(root, 'outside') - file(join(outside, 'icon.svg'), 'outside') + it.each(['inside', 'outside'])('checks an icon reached through a directory link %s the bundle', (location) => { + manifest({ './icon': './art/icon.svg' }) + const destination = join(location === 'inside' ? dir : root, 'art-target') + file(join(destination, 'icon.svg'), location) const link = join(dir, 'art') - symlinkSync(outside, link, 'junction') + symlinkSync(destination, link, 'junction') try { - expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') - } finally { - unlinkSync(link) - } - const inside = join(dir, 'images') - file(join(inside, 'icon.svg'), 'inside') - symlinkSync(inside, link, 'junction') - try { - expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/svg+xml;base64,${Buffer.from('inside').toString('base64')}`) + const meta = readPluginMeta('localized', parentURL, { bundleDirectory: dir }) + if (location === 'outside') expect(meta?.error).toContain('icon must remain inside its bundle directory') + else expect(meta?.icon).toBe(`data:image/svg+xml;base64,${Buffer.from(location).toString('base64')}`) } finally { unlinkSync(link) } @@ -394,9 +393,8 @@ describe('plugin locale display metadata', () => { }) const specifier = 'localized/locale/en.json' expect(resolveSync.mock.calls).toEqual(version === 'v1' - ? [[specifier, parentURL, {}], [specifier, parentURL, {}], ['localized/package.json', parentURL, {}]] - : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }], - [parentURL, { specifier: 'localized/package.json', attributes: {} }]]) + ? [[specifier, parentURL, {}], [specifier, parentURL, {}]] + : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]]) }) it('rejects case-equivalent language names in a directory listing', () => { @@ -476,7 +474,7 @@ describe('plugin display metadata with read-only resolver stacks', () => { } it.each(['profile', 'native'] as const)('falls back to package fields when %s resolution finds no locale resources', (lookup) => { - expect(readPluginMeta('plain', parentFor(lookup))).toEqual({ title: 'plain', description: 'Plain introduction' }) + expect(readPluginMeta('plain', parentFor(lookup), { bundleDirectory: join(bundle, 'node_modules', 'plain') })).toEqual({ title: 'plain', description: 'Plain introduction' }) }) it.each(['profile', 'native'] as const)('reads exported locale resources through %s resolution', (lookup) => { diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts index 8b807ffeca..d066329d36 100644 --- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts @@ -53,6 +53,27 @@ function resolution( } describe('profile package metadata service', () => { + it('reads translated metadata from the selected local package without importing its entry', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-localized-package-service-')) + roots.push(root) + const packageDir = join(root, 'node_modules', 'localized') + file(join(packageDir, 'package.json'), JSON.stringify({ + name: 'localized', exports: { './private': './index.js', './private/locale/*.json': './locale/*.json' }, + })) + file(join(packageDir, 'index.js'), 'throw new Error("must not execute")\n') + file(join(packageDir, 'locale', 'en.json'), '{"meta":{"title":"Local plugin"}}') + file(join(packageDir, 'locale', 'zh.json'), '{"meta":{"title":"本地插件"}}') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(PluginPackages) + const parent = pathToFileURL(join(root, 'entry.mjs')).href + expect(ctx.pluginPackages.metaOf('localized/private', parent)).toEqual({ title: { en: 'Local plugin', zh: '本地插件' } }) + expect(ctx.pluginPackages.metaOf('localized', parent)).toBeUndefined() + expect(ctx.pluginPackages.metaOf('node:fs', parent)).toBeUndefined() + file(join(root, 'node_modules', 'invalid', 'package.json'), '{') + expect(ctx.pluginPackages.metaOf('invalid', parent)?.error).toContain('invalid') + }) + it('resolves module URLs and package metadata through the current resolution', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-profile-package-service-')) roots.push(root) @@ -78,6 +99,7 @@ describe('profile package metadata service', () => { }) const require = createRequire(join(profileDir, 'entry.cjs')) expect(require.resolve('metadata-lib')).toBe(realpathSync(join(first, 'index.cjs'))) + expect(ctx.pluginPackages.metaOf('metadata-lib/feature', parentURL)).toEqual({ title: { en: 'Profile feature' } }) expect(ctx.pluginPackages.packageOf('node:fs', parentURL)).toBeUndefined() expect(ctx.pluginPackages.packageOf('./local.js', parentURL)).toBeUndefined() diff --git a/packages/boot/hmr/tests/modules.spec.ts b/packages/boot/hmr/tests/modules.spec.ts index c4f4b3dffd..93b96a007c 100644 --- a/packages/boot/hmr/tests/modules.spec.ts +++ b/packages/boot/hmr/tests/modules.spec.ts @@ -62,11 +62,11 @@ it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Load const { ctx, module, imports, reload } = await fixture(version) const mounted: string[] = [] const disposed: string[] = [] - const before = { meta: { title: 'Before' }, apply(ctx: Context, config: { value: string }) { + const before = { apply(ctx: Context, config: { value: string }) { mounted.push(`before:${config.value}`) ctx.effect(() => () => { disposed.push('before') }) } } - const after = { meta: { title: 'After' }, apply(ctx: Context, config: { value: string }) { + const after = { apply(ctx: Context, config: { value: string }) { mounted.push(`after:${config.value}`) ctx.effect(() => () => { disposed.push('after') }) } } @@ -80,7 +80,6 @@ it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Load ctx.on('hmr/reload', event) reload.stashed.add(job.url) await reload.partialReload() - expect(entry.plugin).toBe(after) expect(mounted.at(-1)).toBe('after:current') expect(disposed).toEqual(['before', 'before']) expect(event).toHaveBeenCalledOnce() @@ -216,7 +215,6 @@ it('rejects failed replacement activation and restores the prior plugin', async expect(cache.get(job.url)).toBe(job) expect(mounted).toEqual(['original', 'original']) expect(entry.fiber?.runtime?.callback).toBe(original.apply) - expect(entry.plugin).toBe(original) expect(event).not.toHaveBeenCalled() }) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 1e70182f4d..1e5cf28349 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: d1b10426e743b63a - zh: 115d6ff66dd8c707 + en: 3d5643fb77f5cf24 + zh: 95c735d4bf90557b /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 5835bf2a27..1724df54d5 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -`listBundles` includes optional display `meta` for each bundle, including disabled bundles. Declared plugin rows do not trigger manifest or locale-resource reads; the Client joins their loaded-plugin metadata from the inventory. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Host reads exported locale text for every row; only bundles also use package-manifest fallback and exported icons. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. The `plugin_manager` tool omits UI display metadata from list results. `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 93fe0089e6..fabc314ab0 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -`listBundles` 为各组合包提供可选的展示 `meta`,包括已禁用的组合包。声明的插件行不会触发 manifest 或 locale 资源读取;Client 从清单中合并已加载插件的元信息。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Host 为每行读取导出的 locale 文本;只有组合包还使用包清单回退和导出的图标。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index fecc2d6386..b0f5b22ba7 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -12,7 +12,7 @@ import z from '@deepseek-ai/schemastery' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { - readLoadedPluginMeta, readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, + readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, @@ -301,7 +301,7 @@ export class PluginManager extends TypertRemoteService { const compatibility = evaluatePluginCompatibility(info, exemptions) if (compatibility !== undefined && !compatibility.exempted) throw new ManagementFailure('incompatible-version', [incompatiblePlugin(compatibility)]) const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) - const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href) + const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href, { bundleDirectory: dir }) bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), ...meta === undefined ? {} : { meta }, @@ -633,21 +633,23 @@ export class PluginManager extends TypertRemoteService { const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file)) // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. - const live = new Map }>() + const live = new Map() for (const entry of this.ctx.loader.entries()) { - if (entry.parent.tree.ctx.fiber.entry?.id !== 'include') continue /* v8 ignore next -- the Loader gives every entry an id before it is listed */ if (typeof entry.options.id === 'string') live.set(entry.options.id, { - entryId: pluginEntryId(entry.id), moduleName: entry.options.name, meta: readLoadedPluginMeta(entry.plugin), + entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl, }) } const rows: BundleRowInfo[] = [] + const packages = this.ctx.get('pluginPackages') for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) { if (typeof row.id !== 'string' || typeof row.name !== 'string') continue - const entry = live.get(row.id) + const active = live.get(row.id) + const entryId = active?.entryId + const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href + const meta = packages?.metaOf(row.name, base) rows.push({ rowId: row.id, moduleName: row.name, - ...entry === undefined ? {} : { entryId: entry.entryId }, - ...entry?.moduleName !== row.name || entry.meta === undefined ? {} : { meta: entry.meta } }) + ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } }) } const declared = new Set(rows.map(row => row.rowId)) const overrides = [...new Set(patches.flatMap(item => diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 6b9c88cd4c..b87f231e60 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -37,8 +37,8 @@ export interface BundleRowInfo { rowId: string /** The module the row names. */ moduleName: string - /** Optional display metadata published for this plugin. */ - meta?: Pick + /** Local package display metadata, including rows whose bundle is disabled. */ + meta?: PluginLocalizedMeta /** The Loader entry carrying this row, when exactly one live entry has its id. */ entryId?: PluginEntryId } diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 54847d0687..81acd6ddf8 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -83,23 +83,6 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection } } -it('joins bundle rows to loaded plugin text without copying ordinary icons', async () => { - const { manager, ctx } = await fixture('startup', false, undefined, {}, undefined, (dir) => { - writeFileSync(join(dir, 'node_modules', 'extra', 'plugin.mjs'), ` - export const meta = { title: { en: 'Loaded child' }, description: 'Explicit child description', icon: 'ignored.svg' }; - export function apply() {} - `) - }) - const row = (await manager.listBundles()).find(bundle => bundle.name === 'extra')!.rows[0]! - expect(row.meta).toEqual({ title: { en: 'Loaded child' }, description: 'Explicit child description' }) - await ctx.loader.root.update([...ctx.loader.root.data, { id: 'managed', name: row.moduleName }]) - expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')!.rows[0]).toEqual(row) - ctx.loader.builtins.replacement = { meta: { title: 'Different module' }, apply() {} } - await ctx.loader.resolve(row.entryId!).update({ name: 'cordis:replacement' }) - await ctx.loader.await() - expect((await manager.listBundles()).find(bundle => bundle.name === 'extra')!.rows[0]?.meta).toBeUndefined() -}) - it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => { const { manager, dir, connection } = await fixture() const before = readFileSync(join(dir, 'package.json'), 'utf8') @@ -353,7 +336,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }]) }) -it.each(['native', 'runtime'] as const)('reads disabled bundle metadata without reading plugin locale metadata with %s resolution', async (mode) => { +it.each(['native', 'runtime'] as const)('reads a disabled bundle and its independent exported plugins without running them with %s resolution', async (mode) => { const { ctx, manager, dir, bundle, profile } = await fixture('startup') bundle('localized', [{ id: 'first', name: 'local-child/first' }, { id: 'second', name: 'local-child/second' }]) const root = join(dir, 'node_modules', 'localized') @@ -403,10 +386,9 @@ it.each(['native', 'runtime'] as const)('reads disabled bundle metadata without await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {}) const result = (await manager.listBundles()).find(row => row.name === 'localized') expect(result).toMatchObject({ enabled: false, meta: { title: { en: 'Local bundle', zh: '本地组合包' } }, rows: [ - { rowId: 'first', moduleName: 'local-child/first' }, - { rowId: 'second', moduleName: 'local-child/second' }, + { rowId: 'first', moduleName: 'local-child/first', meta: { title: { en: 'First plugin' } } }, + { rowId: 'second', moduleName: 'local-child/second', meta: { title: { en: 'Second plugin' } } }, ] }) - expect(result?.rows.every(row => row.meta === undefined)).toBe(true) expect(result?.rows[0]?.entryId).toBeUndefined() expect(readPluginMeta('local-child/private', parentURL)).toBeUndefined() if (mode === 'runtime') { diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 8a2638ff04..e48806eabf 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: 45cea50a76b3f04d - zh: 0f17c0ea57ca43ce + en: ea8db92be84d0156 + zh: aef8a8fe61dfb34d /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: 20e7310ab6867389 zh: eed2957381abc0bd @@ -36,8 +36,8 @@ en: 8ca48e1365f18adf zh: 91eadd683d2d8b5b /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/registration: - en: aa99865f36b68b81 - zh: cd9d04e034126831 + en: c5a1f14d37433d07 + zh: 75014f10a155fadb /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation/the-store: en: 21a0bbe8eb92229c zh: 096ff63db61992bb diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index e3021f527e..16d5135d0c 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -29,7 +29,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Experimental** for experimental features — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. -Installed bundles display localized titles and descriptions from exported locale resources, falling back to their exported manifest. Only bundle cards and details use `package.json.icon`; absent or undecodable images retain the default artwork. Ordinary plugin rows use text from the loaded plugin's `meta` export and default artwork. Unloaded plugins and missing titles fall back to the full module name; plugin manifests and locale resources are not read for display. See [Display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information. +Installed bundles and their plugin rows display exported locale `meta` titles and descriptions in the current UI language, including while disabled. Only bundle display fields fall back to an accessible exported `package.json`; ordinary plugins use their full module name when a title is absent and omit absent descriptions. Only bundle cards and details display the exported `./icon` image; absent or undecodable images retain the default artwork. Ordinary component rows and their details use generic artwork. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information. The first list read shows a short Official-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. @@ -97,7 +97,7 @@ Package management uses the profile's dependency records: installed bundles can The Host entry exposes `pluginRegistryProbe.fastest()` through the generated Remote interface. It shares pending comparisons, expires cached results, and aborts and awaits outstanding probes on unload. Calls after unload return a rejected Promise. -The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Display text comes from bundle resources, loaded plugin exports, and the page's dictionary. +The browser plugin registers the `plugins` sidebar entry and its `main` panel through `ctx.slots.inject()`, so both follow late slot declaration, locale changes and teardown. The page is global and belongs to no Session. Display text comes from package metadata and the page's dictionary. ### The store diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index e907e38c17..e5d35e9b0b 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于实验性功能的带**实验性**标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 -已安装的组合包从导出的 locale 资源显示本地化标题与描述,缺失时回退到其导出的 manifest。只有组合包卡片和详情使用 `package.json.icon`;未声明或无法解码时保留默认插画。普通插件行使用已加载插件的 `meta` 导出文本和默认插画。未加载插件或缺失标题时回退到完整模块名;展示时不读取插件 manifest 或 locale 资源。作者格式见[展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。 +已安装的组合包及其插件行按当前界面语言显示导出的 locale `meta` 标题与描述,禁用时也可显示。只有组合包的展示字段回退到可访问且已导出的 `package.json`;普通插件缺少标题时使用完整模块名,缺少描述时不显示描述。只有组合包卡片和详情显示导出的 `./icon` 图片;未声明或无法解码时保留默认插画。普通组件行及其详情使用通用插画。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。 首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 @@ -97,7 +97,7 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`,共享进行中的比较、让缓存按时过期,并在卸载时中止和等待未完成探测。卸载后调用会返回拒绝的 Promise。 -浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。显示文本来自组合包资源、已加载插件的导出与页面字典。 +浏览器插件通过 `ctx.slots.inject()` 注册 `plugins` 侧栏入口与它的 `main` 面板,使两者跟随 slot 延迟声明、本地化变化与销毁。页面为全局页面,不属于任何 Session。显示文本来自包元信息与页面字典。 ### store diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index b486c66ffe..dba538350d 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -136,13 +136,15 @@ function itemArtwork(id: string): ReactNode { } /** Manifest images remain isolated from the page DOM; a failed decode keeps the position's default artwork. */ -function PackageArtwork({ src }: { +function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD_ARTWORK_SIZE }: { readonly src: string | undefined + readonly row?: boolean + readonly size?: number }): ReactNode { const [failedSource, setFailedSource] = useState() - const size = CARD_ARTWORK_SIZE + const Fallback = row ? PluginArtworkSubagent : PluginArtworkDefault return src === undefined || src === failedSource - ? + ? : { setFailedSource(src) }} /> } @@ -233,7 +235,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
- +
{configure?.has(row) === true ? ( @@ -258,6 +260,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { onChange={(enabled) => { toggle.onSetEnabled(row, enabled) }} />}
+ ))} @@ -508,7 +511,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} - icon={} + icon={} actions={
{renderSlot('plugins.detail.actions', { subject })}
} />
@@ -520,6 +523,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: {

{row.moduleName}

{description ?? renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}

+
{renderSlot('plugins.row.config', { view: 'page', form }, { entryKey: key })} {renderSlot('plugins.detail.section', { subject })} 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 148247e52a..53d7bfd0c9 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -504,10 +504,10 @@ describe('PluginManagerPage', () => { } }) - it('renders bundle icons with decode recovery and generic artwork for ordinary rows', () => { + it('renders bundle icons with decode fallback and keeps ordinary row artwork generic', () => { const icon = 'data:image/svg+xml;base64,PHN2Zy8+' const updatedIcon = 'data:image/png;base64,cG5n' - const bundle = pkg({ meta: { icon }, rows: [row(), row({ entryId: 'plain' as PluginEntryId, rowId: 'plain', moduleName: 'plain' })] }) + const bundle = pkg({ meta: { icon }, rows: [row({ meta: { icon } }), row({ entryId: 'plain' as PluginEntryId, rowId: 'plain', moduleName: 'plain' })] }) const { set } = renderTab({ packages: [bundle] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }) const image = () => document.querySelector('[data-plugin-package] img, [data-plugin-detail] img')! expect(image().getAttribute('src')).toBe(icon) @@ -672,11 +672,13 @@ describe('PluginManagerPage', () => { }) it('translates row text, searches current copy and technical identities, and keeps configuration keys unchanged', () => { + const error = 'locale/zh.json: invalid description' const localized = row({ moduleName: '@acme/dsh-sidebar-widget', meta: { title: { en: 'Sidebar component', zh: '导航组件' }, description: { en: 'Sidebar navigation', zh: '侧边导航' }, + error, }, }) const rows = [localized, ...Array.from({ length: 10 }, (_, index) => row({ @@ -698,6 +700,7 @@ describe('PluginManagerPage', () => { expect(screen.getByText('sidebar')).toBeTruthy() expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy() expect(screen.getByText('侧边导航')).toBeTruthy() + expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') }) expect(toggle).toHaveProperty('disabled', false) fireEvent.click(toggle) @@ -718,6 +721,7 @@ describe('PluginManagerPage', () => { expect(screen.getByText('侧边导航')).toBeTruthy() expect(screen.getByText('sidebar')).toBeTruthy() expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy() + expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: zh.backToPackage.replace('{name}', '个人工具') })) expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('个人工具') }) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 7d0889f82f..3a91709247 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -18,8 +18,8 @@ en: 67637808e7940157 zh: d4d0716ea4d33e4a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card: - en: ebb909bf10f36a09 - zh: 66482b4535c21a5c + en: bc36dca3cfcb1566 + zh: 2203e16ad4f2965d /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher: en: 84e5938329199a22 zh: 6f5cfaac56433e8b diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 8906c87a0f..06082453a1 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -31,7 +31,7 @@ A failed installation with pending pnpm build permissions offers **Allow these s ### Reading a card -Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies the loaded plugin's `meta` text; the Client selects the current language. Unloaded rows use module names. Full module specifiers remain available in details and search. Missing descriptions are omitted. This compact naming applies only to Settings, not the sidebar's Plugins page. +Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies exported locale fields in `meta`, without package-manifest fallbacks; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 2a1ceba6f0..d3071c83ce 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 提供已加载插件的 `meta` 文本,Client 选择当前语言。未加载的行使用模块名。详情与搜索仍保留完整模块标识。缺失的描述不显示。这种短名显示仅用于设置页,不影响侧栏的插件页。 +每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供导出的 locale 字段,不回退到包清单;Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 1bd97e4a98..57810b993a 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -122,12 +122,13 @@ function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset /** One expandable plugin card; the caller owns the trailing status content. */ function PluginCard({ - rowKey, moduleName, title, description, entryId, trailing, ariaLabel, failed, expanded, onToggle, children, + rowKey, moduleName, title, description, metadataError, entryId, trailing, ariaLabel, failed, expanded, onToggle, children, }: { readonly rowKey: string readonly moduleName: string readonly title: string readonly description: string | undefined + readonly metadataError: string | undefined readonly entryId: string | null readonly trailing: ReactNode readonly ariaLabel: string @@ -170,6 +171,7 @@ function PluginCard({ ) : null} + {metadataError === undefined ? null :

{metadataError}

} {open ?
{children}
: null} ) @@ -340,6 +342,7 @@ export function PluginInventorySettingsTab( moduleName={row.moduleName} title={title} description={description} + metadataError={row.meta?.error === undefined ? undefined : t('metadataError', { error: row.meta.error })} entryId={row.entryId} failed={failed} expanded={expanded} @@ -388,6 +391,7 @@ export function PluginInventorySettingsTab( moduleName={entry.moduleName} title={title} description={description} + metadataError={entry.meta?.error === undefined ? undefined : t('metadataError', { error: entry.meta.error })} entryId={entry.entryId} failed={failed} expanded={expanded} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index a73ed1be7e..3fb7662a86 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -8,6 +8,7 @@ export const zh = { clientSyncFailed: '本页面的插件未能完成同步;服务端的启用状态保持不变。', clientSyncRetry: '重试本页面同步', error: '暂时无法读取插件。', + metadataError: '包元信息错误:{error}', retry: '重试', search: '搜索插件', empty: '暂无插件。', @@ -54,6 +55,7 @@ export const en = { clientSyncFailed: 'Some plugins could not sync on this page. Host enablement is unchanged.', clientSyncRetry: 'Retry this page', error: 'Plugins are temporarily unavailable.', + metadataError: 'Package metadata error: {error}', retry: 'Retry', search: 'Search plugins', empty: 'No plugins are available.', diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 5727b1e9e8..9a87e8d6d8 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -166,12 +166,15 @@ describe('PluginInventorySettingsTab', () => { }) it('localizes global and preset metadata at render time while preserving identities and query state', async () => { + const globalError = 'locale/zh.json: invalid title' + const presetError = 'locale/fr.json: invalid description' const snapshot: Snapshot = { entries: [{ entryId: 'include:global-navigation' as PluginEntryId, moduleName: '@acme/navigation', enabled: true, fiberPhase: 'active', meta: { title: { en: 'Navigation', zh: '导航' }, description: { en: 'Global navigation controls', zh: '全局导航控件' }, + error: globalError, }, }], agentPresets: [{ @@ -181,6 +184,7 @@ describe('PluginInventorySettingsTab', () => { meta: { title: { en: 'Session runner', zh: '会话执行器' }, description: { en: 'Run per session', zh: '运行会话命令' }, + error: presetError, }, }], }], @@ -194,6 +198,8 @@ describe('PluginInventorySettingsTab', () => { const preset = screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' }) expect(document.getElementById(global.getAttribute('aria-describedby')!)?.textContent).toBe('Global navigation controls') expect(document.getElementById(preset.getAttribute('aria-describedby')!)?.textContent).toBe('Run per session') + expect(screen.getByText(en.metadataError.replace('{error}', globalError))).toBeTruthy() + expect(screen.getByText(en.metadataError.replace('{error}', presetError))).toBeTruthy() expect(global.closest('li')?.getAttribute('data-failed')).toBeNull() fireEvent.click(global) expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/navigation') @@ -207,6 +213,7 @@ describe('PluginInventorySettingsTab', () => { expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy() expect(screen.getByText('全局导航控件')).toBeTruthy() expect(screen.getByText('运行会话命令')).toBeTruthy() + expect(screen.getByText(zh.metadataError.replace('{error}', presetError))).toBeTruthy() expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner') const search = screen.getByRole('searchbox', { name: zh.search }) for (const query of ['导航', '全局导航控件', '@acme/navigation', 'include:global-navigation']) { @@ -284,13 +291,16 @@ describe('PluginInventorySettingsTab', () => { expect(list).toHaveBeenCalledOnce() }) - it('keeps disabled rows without metadata inspectable by their full module specifier', async () => { + it('keeps metadata-error-only rows inspectable by their full module specifier', async () => { + const error = 'locale/zh.json: invalid title' await renderReady({ entries: [{ entryId: 'include:legacy' as PluginEntryId, moduleName: '@acme/dsh-legacy', enabled: false, fiberPhase: null, + meta: { error }, }], }) fireEvent.click(globalToggle()) + expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy() const card = screen.getByRole('button', { name: 'legacy, Disabled' }) expect(card).toHaveProperty('disabled', false) expect(card.closest('li')?.getAttribute('data-failed')).toBeNull() diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 78761dfa6d..874b1d801a 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -21,8 +21,8 @@ en: 97ef5916c04bf2a1 zh: 51f9d2bc3fca7936 /deepseek-ai-dsh-experimental-agent-team-profile/use-this-package/what-you-get: - en: f0e95e60095fc5cb - zh: 8c36603b423475d0 + en: 6631846ff0927691 + zh: 650c474b56b423e2 /deepseek-ai-dsh-experimental-agent-team-profile/understand-the-implementation: en: dbfca16ca57c0723 zh: 38ccf89793e21f68 diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 053f44b673..9eaed20148 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -48,7 +48,7 @@ In an existing profile’s `package.json`, keep `@deepseek-ai/dsh-experimental-a The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. Direct delegation uses `spawn_teammate`, which supports fresh and fork context. The `subagent` and `subagent_fork` tools and overlapping global child controls are disabled. Workflow retains the base profile’s `spawn` provider, while the underlying Subagent services and both providers remain available to teammates and workflow. -In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. The Plugins page reads the bundle's [icon](icon.svg) from its `package.json.icon` declaration, including while the bundle is disabled. +In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. The Plugins page reads the bundle's [icon](icon.svg) through its `./icon` resource export, including while the bundle is disabled. ----- diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index bd68373392..920834171f 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -48,7 +48,7 @@ dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile 本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。直接委派使用支持 fresh 和 fork 上下文的 `spawn_teammate`。`subagent`、`subagent_fork` 工具和名称重叠的全局 child control 均被禁用。Workflow 保留 base profile 的 `spawn` 提供方,底层 Subagent 服务和两个提供方仍供 teammate 与 workflow 使用。 -在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。插件页通过组合包的 `package.json.icon` 声明读取其[图标](icon.svg),组合包禁用时也会显示。 +在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。插件页通过组合包的 `./icon` 资源导出读取其[图标](icon.svg),组合包禁用时也会显示。 ----- diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 4195a6f6e2..852dd3d9b3 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -2,7 +2,6 @@ "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Agent Teams collaboration, tools, and Web UI in one experimental bundle", "version": "0.1.7-rc.2", - "icon": "./icon.svg", "publishConfig": { "access": "public" }, @@ -15,6 +14,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { + "./icon": "./icon.svg", ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 4ea17006e9..e877961c20 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -2,7 +2,6 @@ "name": "@deepseek-ai/dsh-experimental-auto-review", "description": "Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset", "version": "0.1.7-rc.2", - "icon": "./icon.svg", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", @@ -12,6 +11,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { + "./icon": "./icon.svg", ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 8ea8d2f163..5a3de17a4a 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -2,7 +2,6 @@ "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", "version": "0.1.7-rc.2", - "icon": "./icon.svg", "publishConfig": { "access": "public" }, @@ -15,6 +14,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { + "./icon": "./icon.svg", ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" diff --git a/packages/experimental/schedule-bundle/package.json b/packages/experimental/schedule-bundle/package.json index 884b25a471..cd36b381ea 100644 --- a/packages/experimental/schedule-bundle/package.json +++ b/packages/experimental/schedule-bundle/package.json @@ -2,7 +2,6 @@ "name": "@deepseek-ai/dsh-experimental-schedule-bundle", "description": "Optional bundle that adds Schedule, its task page, and time context to the Web composition", "version": "0.1.7-rc.2", - "icon": "./icon.svg", "publishConfig": { "access": "public" }, @@ -15,6 +14,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { + "./icon": "./icon.svg", ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" diff --git a/packages/experimental/schedule-bundle/tests/patch.spec.ts b/packages/experimental/schedule-bundle/tests/patch.spec.ts index a8c8ab8525..b7463e14b6 100644 --- a/packages/experimental/schedule-bundle/tests/patch.spec.ts +++ b/packages/experimental/schedule-bundle/tests/patch.spec.ts @@ -11,7 +11,6 @@ const root = fileURLToPath(new URL('..', import.meta.url)) interface Manifest { name?: string - icon?: string private?: boolean publishConfig?: { access?: string } exports?: Record @@ -26,7 +25,7 @@ describe('experimental Schedule bundle', () => { expect(manifest.name).toBe('@deepseek-ai/dsh-experimental-schedule-bundle') expect(manifest.private).toBeUndefined() expect(manifest.publishConfig?.access).toBe('public') - expect(manifest.icon).toBe('./icon.svg') + expect(manifest.exports?.['./icon']).toBe('./icon.svg') expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.exports?.['./locale/*.json']).toBe('./locale/*.json') expect(manifest.exports?.['./cordis.patch.yml']).toBe('./cordis.patch.yml') diff --git a/packages/experimental/voice-input-bundle/package.json b/packages/experimental/voice-input-bundle/package.json index 7c63262962..c859a80cbb 100644 --- a/packages/experimental/voice-input-bundle/package.json +++ b/packages/experimental/voice-input-bundle/package.json @@ -2,7 +2,6 @@ "name": "@deepseek-ai/dsh-experimental-voice-input-bundle", "description": "Experimental voice input with local SenseVoice; downloads its runtime on first use", "version": "0.1.7-rc.2", - "icon": "./icon.svg", "publishConfig": { "access": "public" }, @@ -15,6 +14,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "exports": { + "./icon": "./icon.svg", ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7fc7066cf8..04704013e6 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4461,7 +4461,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentPresetCompositionRow', - declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n readonly meta?: LoadedPluginMeta;\n}', + declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', }, { name: 'AgentPresetDocument', @@ -4685,7 +4685,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleRowInfo', - declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n meta?: Pick;\n entryId?: PluginEntryId;\n}', + declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n meta?: PluginLocalizedMeta;\n entryId?: PluginEntryId;\n}', }, { name: 'ChangeResult', @@ -5615,10 +5615,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmRuntime', declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n fileRequestText(ref: FileAttachmentRef): string;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions) /* …truncated — full shape in source */', }, - { - name: 'LoadedPluginMeta', - declaration: 'export type LoadedPluginMeta = Pick;', - }, { name: 'LocalAtInput', declaration: 'export interface LocalAtInput {\n readonly date: string;\n readonly time: string;\n readonly time_zone: string;\n}', @@ -5941,7 +5937,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginInventoryEntry', - declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: Pick;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', + declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: PluginLocalizedMeta;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', }, { name: 'PluginLocalizedMeta', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 453c1853a9..c1c66a813b 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -18,8 +18,8 @@ en: 10bbb42c37ba25d2 zh: d628b1c9db7847e4 /deepseek-ai-dsh-host-plugin-inventory/use-this-package/what-a-snapshot-contains: - en: 3163ae623ef0498f - zh: 4d5af76473effc02 + en: c8afdb1aec1c97bd + zh: 0a8a765e02caab91 /deepseek-ai-dsh-host-plugin-inventory/use-this-package/per-preset-compositions: en: dfc89b14d4c0d21e zh: 062b233951ed157b @@ -30,8 +30,8 @@ en: 42b15ac6321706aa zh: 4ed0861911abaf5d /deepseek-ai-dsh-host-plugin-inventory/understand-the-implementation/design-concept: - en: 9744cc025796bc0c - zh: a6e2fc08dfb6bb31 + en: b887657765809b3d + zh: c74c0d6746a9f60c /deepseek-ai-dsh-host-plugin-inventory/understand-the-implementation/the-phase-mapping: en: 67fd54c8c49f6dbc zh: f6743f8f5c34b886 diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index edbd49358c..2ca6520f98 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -31,7 +31,7 @@ Call `pluginInventory/list` when a client or settings page needs to show what is Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped. -Loaded plugin rows can carry optional `meta` with a title and description from the plugin object. Function plugin namespaces export `meta`; default-exported service classes declare static `meta`. The Client selects its language. Only bundles own custom icons. Unloaded rows use their module names. +Loader and preset rows can carry optional `meta` with a title, description, or metadata diagnostic. The Host returns available translations and literal fallbacks; the Client selects its language. Metadata diagnostics do not change enablement or fiber phase. ### Per-preset compositions @@ -53,7 +53,7 @@ The inventory is a snapshot for display and diagnostics: a client can render the The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal `plugin/status` events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. The agent-preset roster is an optional peer resolved per call through `ctx.get('agentPresets')`: its `compositionInventory()` owns preset composition reads, and this package maps root-fiber states onto the public phases. -Display metadata is projected from the already-loaded plugin object. Inventory reads do not import modules or read plugin manifests or locale files; declaration-only preset rows therefore carry no display metadata. +Display metadata comes from the optional `pluginPackages` service using each Loader tree's resolution base, or the gateway context's base for preset rows. Without the service or the applicable base, `meta` is absent. Metadata reads use exported locale JSON only, without package-manifest fallback or icons, and do not load or activate plugins. ### The phase mapping diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index 6983116bba..163847bfe0 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。 -已加载的插件行可以携带可选的 `meta`,包含插件对象提供的标题与描述。函数插件的命名空间导出 `meta`;默认导出的服务类声明静态 `meta`。Client 选择语言。只有组合包拥有自定义图标。未加载的行使用模块名。 +Loader 行与预设行可以携带可选的 `meta`,其中包含标题、描述或元信息诊断。Host 返回可用翻译与字面回退文本,由 Client 选择语言。元信息诊断不改变启停状态或 fiber 阶段。 ### 每个预设的组合 @@ -53,7 +53,7 @@ kind: "package-reference" 网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 `plugin/status` 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。agent preset roster 是每次调用经 `ctx.get('agentPresets')` 解析的可选伙伴:预设组合读取由它的 `compositionInventory()` 负责,本包把根 Fiber 状态映射到公共阶段。 -展示元信息从已加载的插件对象投影。清单读取不会导入模块,也不读取插件 manifest 或 locale 文件;因此,仅有声明的预设行不携带展示元信息。 +展示元信息来自可选的 `pluginPackages` 服务:Loader 行使用其所属树的解析基准,预设行使用网关上下文的基准。服务或对应基准不存在时,`meta` 缺席。元信息只读取导出的 locale JSON,不使用包清单回退或图标,也不会加载或激活插件。 ### 阶段映射 diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 47bf6bf812..b122998414 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -2,9 +2,9 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' -import { readLoadedPluginMeta } from '@deepseek-ai/dsh-app-boot' // Type-only: the optional agent-preset roster resolved through `ctx.get`. import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import type {} from '@deepseek-ai/dsh-app-boot' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' @@ -77,19 +77,21 @@ export default PluginInventoryGateway /** Read current Loader entries and optional preset compositions. * @param ctx Context with the Loader service. - * @returns Current inventory with no separate runtime cache. + * @returns Current inventory with optional display metadata and no separate runtime cache. */ export async function readPluginInventory(ctx: Context): Promise { const entries: PluginInventoryEntry[] = [] + const packages = ctx.get('pluginPackages') for (const entry of ctx.loader.entries()) { if (entry.options.group) continue - const meta = readLoadedPluginMeta(entry.plugin) + const base = entry.parent.tree.ctx.baseUrl + const meta = base === undefined ? undefined : packages?.metaOf(entry.options.name, base) entries.push({ entryId: pluginEntryId(entry.id), moduleName: entry.options.name, - ...meta === undefined ? {} : { meta }, enabled: !entry.disabled, fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + ...meta === undefined ? {} : { meta }, }) } const presets = ctx.get('agentPresets') @@ -98,10 +100,14 @@ export async function readPluginInventory(ctx: Context): Promise ({ ...composition, - rows: composition.rows.map(({ fiberState, ...row }) => ({ - ...row, - fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState], - })), + rows: composition.rows.map(({ fiberState, ...row }) => { + const meta = ctx.baseUrl === undefined ? undefined : packages?.metaOf(row.moduleName, ctx.baseUrl) + return { + ...row, + fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState], + ...meta === undefined ? {} : { meta }, + } + }), }), ) return { entries, agentPresets, ...management } diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index c15a65effd..b6ebc2d3ce 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -18,8 +18,8 @@ export interface PluginInventoryEntry { readonly entryId: PluginEntryId /** Exact module specifier imported by the Loader entry. */ readonly moduleName: string - /** Optional display metadata published for this plugin. */ - readonly meta?: Pick + /** Local package display metadata, independent of whether the entry is enabled. */ + readonly meta?: PluginLocalizedMeta /** Effective Loader enablement, including disabled ancestor groups. */ readonly enabled: boolean readonly fiberPhase: PluginFiberPhase @@ -34,8 +34,8 @@ export interface AgentPresetPluginRow { readonly entryId: string | null /** Module specifier the row names. */ readonly moduleName: string - /** Optional display metadata published for this plugin. */ - readonly meta?: Pick + /** Local package display metadata, independent of whether the preset is mounted. */ + readonly meta?: PluginLocalizedMeta /** * Effective enablement, including disabled ancestor groups. `'conditional'` * marks a `!!js` disabled expression on a composition no session has diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 190f1a99e7..667d8ac66f 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -66,56 +66,7 @@ function metadataFixture(mode: 'native' | 'runtime') { } describe('PluginInventoryGateway', () => { - it.each(['active', 'pending', 'failed'] as const)('reads only explicit text from a loaded %s module', async (phase) => { - const { dir, baseUrl } = metadataFixture('native') - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'local-plugin', type: 'module', exports: './index.js' })) - writeFileSync(join(dir, 'index.js'), ` - export const meta = { title: { en: 'Export title', zh: '导出标题' }, description: 'Export description', icon: 'ignored.svg', error: 'ignored' }; - export const inject = ${JSON.stringify(phase === 'pending' ? ['neverReady'] : [])}; - export function apply() { ${phase === 'failed' ? 'throw new Error("fixture failure")' : ''} } - `) - const { ctx, inventory } = await harness(baseUrl) - const entryId = await ctx.loader.create({ name: pathToFileURL(join(dir, 'index.js')).href }) - await ctx.loader.await() - expect((await inventory.list()).entries).toEqual([{ - entryId, moduleName: pathToFileURL(join(dir, 'index.js')).href, enabled: true, fiberPhase: phase, - meta: { title: { en: 'Export title', zh: '导出标题' }, description: 'Export description' }, - }]) - await ctx.loader.update(entryId, { disabled: true }) - await ctx.loader.await() - expect((await inventory.list()).entries[0]?.meta).toBeUndefined() - }) - - it('retains loaded plugin identity only for the current fiber', async () => { - const { ctx, inventory } = await harness() - const first = { meta: { title: 'First' }, apply() {} } - const second = { meta: { title: 'Second' }, apply() {} } - ctx.loader.builtins.feature = first - const entryId = await ctx.loader.create({ name: 'cordis:feature' }) - const entry = ctx.loader.resolve(entryId) - expect(entry.plugin).toBe(first) - await ctx.loader.update(entryId, { disabled: true }) - await ctx.loader.await() - expect(entry.plugin).toBeUndefined() - ctx.loader.builtins.feature = second - await ctx.loader.update(entryId, { disabled: false }) - expect(entry.plugin).toBe(second) - expect((await inventory.list()).entries[0]?.meta).toEqual({ title: 'Second' }) - }) - - it('reads static text from a default service class', async () => { - const { ctx, inventory } = await harness() - // oxlint-disable-next-line typescript/no-extraneous-class -- Loader fixture exercises default-exported class metadata. - class Feature { - static meta = { description: 'Service description without a title' } - } - ctx.loader.builtins.feature = { default: Feature } - await ctx.loader.create({ name: 'cordis:feature' }) - expect((await inventory.list()).entries[0]?.meta).toEqual({ description: 'Service description without a title' }) - }) - - it.each(['native', 'runtime'] as const)('ignores package locale metadata while the plugin remains disabled with %s resolution', async (mode) => { + it.each(['native', 'runtime'] as const)('reads local metadata while the package entry remains disabled with %s resolution', async (mode) => { const { dir, baseUrl, resolution, expectUnlinked } = metadataFixture(mode) mkdirSync(join(dir, 'locale'), { recursive: true }) writeFileSync(join(dir, 'package.json'), JSON.stringify({ @@ -131,9 +82,8 @@ describe('PluginInventoryGateway', () => { await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {}) await ctx.loader.create({ name: 'local-plugin/feature', disabled: true }) expect((await inventory.list()).entries).toMatchObject([{ - enabled: false, fiberPhase: null, + enabled: false, fiberPhase: null, meta: { title: { en: 'Local plugin' }, description: { en: 'Local description' } }, }]) - expect((await inventory.list()).entries.every(row => row.meta === undefined)).toBe(true) if (mode === 'runtime') { expectUnlinked() await ctx.fiber.dispose() @@ -142,7 +92,7 @@ describe('PluginInventoryGateway', () => { } }) - it.each(['native', 'runtime'] as const)('ignores package locale metadata for unmounted preset rows with %s resolution', async (mode) => { + it.each(['native', 'runtime'] as const)('resolves preset row metadata from the profile base without loading the plugins with %s resolution', async (mode) => { const { dir, baseUrl, resolution, expectUnlinked } = metadataFixture(mode) mkdirSync(join(dir, 'locale'), { recursive: true }) writeFileSync(join(dir, 'package.json'), JSON.stringify({ @@ -174,6 +124,7 @@ describe('PluginInventoryGateway', () => { rows: [ { entryId: 'feature', moduleName: 'local-plugin/feature', enabled: false, fiberPhase: null, + meta: { title: { en: 'Preset plugin', zh: '预设插件' }, description: { en: 'Preset description' } }, }, { entryId: null, moduleName: 'local-plugin/private', enabled: false, fiberPhase: null }, ], diff --git a/packages/preset/agent-preset-registry/src/composition-inventory.ts b/packages/preset/agent-preset-registry/src/composition-inventory.ts index 6cc19f2079..53333e53d2 100644 --- a/packages/preset/agent-preset-registry/src/composition-inventory.ts +++ b/packages/preset/agent-preset-registry/src/composition-inventory.ts @@ -1,5 +1,4 @@ /** Plugin inventory from declared configuration or an activated revision. */ -import { readLoadedPluginMeta, type LoadedPluginMeta } from '@deepseek-ai/dsh-app-boot' import type { FiberState } from '@deepseek-ai/cordis' import { isJsExpr, type EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { entryListProblem } from './definition.ts' @@ -33,8 +32,6 @@ export interface AgentPresetCompositionRow { readonly condition?: string /** Root-fiber state, present only when read from a live mount. */ readonly fiberState?: FiberState - /** Display text from the mounted plugin; absent for unmounted rows. */ - readonly meta?: LoadedPluginMeta } /** One preset's roster identity beside its composition rows. */ @@ -160,11 +157,9 @@ export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionR const prefix = owner === undefined ? '' : `${owner.id}:` for (const entry of tree.entries()) { if (entry.options.group) continue - const meta = readLoadedPluginMeta(entry.plugin) found.push({ entryId: entry.id.slice(prefix.length), moduleName: entry.options.name, - ...meta === undefined ? {} : { meta }, enabled: !entry.disabled, ...isJsExpr(entry.options.disabled) ? { condition: entry.options.disabled.__jsExpr } : {}, ...entry.fiber === undefined ? {} : { fiberState: entry.fiber.state }, diff --git a/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts b/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts index a3943dace4..b9db854c1e 100644 --- a/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts +++ b/packages/preset/agent-preset-registry/tests/composition-inventory.spec.ts @@ -1,24 +1,6 @@ import { expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import Loader, { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import { definitionComposition, mountedCompositionRows } from '../src/composition-inventory.ts' - -it('reads mounted preset metadata from each live entry and omits disabled metadata', async () => { - const ctx = new Context() - try { - await ctx.plugin(Loader) - ctx.loader.builtins.named = { meta: { title: 'Preset child' }, apply() {} } - await ctx.loader.root.update([ - { id: 'live', name: 'cordis:named' }, - { id: 'off', name: 'cordis:named', disabled: true }, - ]) - const rows = mountedCompositionRows(ctx.loader) - expect(rows[0]?.meta).toEqual({ title: 'Preset child' }) - expect(rows[1]?.meta).toBeUndefined() - } finally { - await ctx.fiber.dispose() - } -}) +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { definitionComposition } from '../src/composition-inventory.ts' it('reports evaluated and unresolved conditions with ancestor enablement', () => { const js = (code: string) => ({ __jsExpr: code }) diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md index 36d803f112..ee5d95037b 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md @@ -28,21 +28,22 @@ A Host-only bundle needs no dependencies, install scripts, or build tool: ## Display metadata and icon -Plugin Manager cards, bundle details, component rows, and the Settings plugin inventory read display text and an icon from the manifest without activating the plugin. Put the title and description in `locale/en.json` (other languages such as `locale/zh.json` use the same fields), and declare the icon as a top-level `icon` in `package.json`: +Plugin Manager and Settings read `meta.title` and `meta.description` from exported locale JSON without activating plugins. Ordinary plugins read only these resources and use generic artwork. Put the English discovery file at `locale/en.json`; other languages use the same fields: ```json { "meta": { "title": "My Decoration", "description": "Draws a badge under the composer." } } ``` +Only bundles can customize icons. Merge these resource exports and publication files into the bundle manifest, retaining its runtime exports: + ```json { - "icon": "./icon.svg", - "exports": { "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, + "exports": { "./locale/*.json": "./locale/*.json", "./icon": "./icon.svg" }, "files": ["locale/*.json", "icon.svg"] } ``` -`icon` is a path relative to the manifest directory; SVG, PNG, JPEG, and WebP up to 256 KiB are accepted, while absolute paths, URLs, paths outside the directory, and symlinks leaving it are rejected. Missing fields fall back to `package.json` `name` and `description` and to the panel's default artwork; malformed metadata produces a diagnostic and keeps the valid text. +Both resource addresses support Node exports remapping. Do not add a JavaScript `meta` export or a top-level manifest `icon` field. The icon target can be SVG, PNG, JPEG, or WebP up to 256 KiB and must stay inside the real bundle directory, including through symlinks. Icon errors retain valid text. To enable optional bundle `name`/`description` fallback, also export `./package.json`; icons do not require it, and ordinary plugin display reads never use it. Missing ordinary titles use module names, missing descriptions are omitted, and absent or undecodable bundle images use default artwork. ## Host plugin export forms diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 47d0408069..90c3969fa4 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -21,8 +21,8 @@ en: b8720f6ac186a8e8 zh: 3e04a4c1bebc2cf4 /deepseek-ai-dsh-typert-generator/use-this-package/analyzing-a-workspace-statically: - en: 88f4e7fc9d4c7701 - zh: cecd35ff4f35bfd2 + en: 4ea0e4977cefa39d + zh: 41bdb6047bc79524 /deepseek-ai-dsh-typert-generator/use-this-package/running-generation-inside-a-tsdown-build: en: cc3f7d23739380f6 zh: 864d15088f581271 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 9c207574e9..6e1a3f6a05 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -47,6 +47,8 @@ After the build, `lib/typert.host.js` and `lib/typert.host.d.ts` exist and the [ Static consumers call `WorkspaceAnalyzer` directly against the workspace's `tsconfig.host.json` and `tsconfig.client.json` aggregates, select a face and package subset, and read the resulting `FaceModel` and type graph without emitting or loading runtime artifacts. `analyzeInBatches()` processes a large package selection through bounded compiler programs with the same model shape, and `discoverPackages()` finds contributing packages without building a type-checker program. +JSON, YAML, SVG, PNG, JPEG, and WebP resource exports contribute no TypeScript declarations and are excluded from the API model. Missing JavaScript or TypeScript export sources remain errors. + ### Running generation inside a tsdown build The package's `./tsdown` subpath provides `typertPlugin()` for the root tsdown config: it lowers standard decorators in TypeScript dependencies before bundling and emits the model-driven face artifacts at the package output root. In `package` mode it emits only the bundled package; in `workspace` mode it emits every explicit contributor once. diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index 9a6fa9e81d..4c161178d4 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -47,6 +47,8 @@ files: 静态消费方直接针对工作区的 `tsconfig.host.json` 与 `tsconfig.client.json` 聚合配置调用 `WorkspaceAnalyzer`,选择 face 与包子集,并在不生成或加载运行时产物的前提下读取生成的 `FaceModel` 与类型图。`analyzeInBatches()` 通过有界的编译器程序处理大批量包选择,模型形态保持一致;`discoverPackages()` 无需构建类型检查程序即可找出参与贡献的包。 +JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明,因此不进入 API 模型。JavaScript 或 TypeScript 导出源文件缺失时仍报错。 + ### 在 tsdown 构建中运行生成 包的 `./tsdown` 子路径为根 tsdown 配置提供 `typertPlugin()`:它在打包前转换 TypeScript 依赖中的标准装饰器,并在包输出根目录生成模型驱动的 face 产物。`package` 模式只生成当前打包的包;`workspace` 模式对每个显式贡献方各生成一次。 diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 726828f0ed..4eb5fece91 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -750,8 +750,8 @@ class FaceAnalyzer { for (const [subpath, target] of targets) { if (target.includes('*') || subpath === './package.json' || subpath === './typert' || subpath === './client/typert' || subpath === './remote' - // Data exports (bundle patch lists, JSON manifests) carry no TypeScript API. - || target.endsWith('.json') || target.endsWith('.yml') || target.endsWith('.yaml')) continue + // Data exports carry no TypeScript API; their owning readers validate the resources. + || /\.(?:json|ya?ml|svg|png|jpe?g|webp)$/iu.test(target)) continue const sourcePath = sourcePathForExport(registration.root, target) const sourceFile = this.sourceFiles.get(realPath(sourcePath)) if (sourceFile === undefined) { diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 8afb3abff8..bae8b76d89 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -1137,6 +1137,8 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { './client/typert': './lib/typert.client.js', './wildcard': './lib/*.js', './data': './lib/data.json', + './icon': './assets/icon.svg', + ...Object.fromEntries(['png', 'jpg', 'jpeg', 'webp', 'PNG'].map(extension => [`./image-${extension}`, `./assets/icon.${extension}`])), ignored: './lib/index.js', } writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) @@ -1147,6 +1149,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { expect(exports.some(item => item.subpath === './fallback' && item.name === 'RuntimeOnly')).toBe(true) expect(exports.some(item => item.subpath === './direct' && item.name === 'Direct')).toBe(true) expect(exports.some(item => item.subpath === './empty')).toBe(false) + expect(exports.some(item => item.subpath === './icon' || item.subpath.startsWith('./image-'))).toBe(false) manifest.exports = './lib/index.js' writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 41446bb257..4076b92654 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 1db86285abf6c499 /deepseek-ai-dsh-package-manifest/use-this-package: - en: 43fb882676bf22dd - zh: 04e512bc4059e5d4 + en: abd041f0cc08d93f + zh: ca08edf3dadc2713 /deepseek-ai-dsh-package-manifest/understand-the-implementation: en: 64e1e57d1c3761e0 zh: 9fc039770ce43c63 diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 7c4c493541..a5d129753e 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -52,7 +52,7 @@ The following metadata fields are optional. Omitting them leaves the format vers | `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | | `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. | -`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title, description, an image data URL resolved from `package.json.icon`, and metadata diagnostics for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. +`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional locale display title, description, and metadata diagnostics. Bundles can also carry an image data URL resolved from their exported `./icon` resource and use package-field text fallback. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. Public composition declarations are defined in [`src/types.ts`](src/types.ts). Internal `configTrees`, `sessionFormatMigration`, and generated `moduleFallback` metadata remain owned by their image-packer, catalog, and launcher readers; the public types do not expose them. diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index 58a150ae32..0d7d6158e1 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -52,7 +52,7 @@ const manifest: DshPackageManifest = { | `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | | `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 | -`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题、描述、从 `package.json.icon` 解析的图片 data URL,以及元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 +`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带可选的 locale 展示标题、描述及元信息诊断。组合包还可以携带从导出的 `./icon` 资源解析的图片 data URL,并使用包字段文本回退。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index cda212f5a8..f6e5fdcf8a 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -12,8 +12,6 @@ export interface DshPackageManifest { version: string /** Package summary for discovery and display. */ description?: string - /** SVG, PNG, JPEG, or WebP file relative to this manifest's directory, at most 256 KiB and contained there after realpath resolution. */ - icon?: string /** Prevent npm publication, for example for local profile projects. */ private?: boolean /** Packages installed alongside this package. */ @@ -41,13 +39,13 @@ export interface DshManifest { /** Literal text or translations indexed by lowercase language id, with a required English fallback. */ export type LocalizedText = string | { readonly en: string; readonly [locale: string]: string } -/** Validated plugin display fields and diagnostics from exported locales, manifests, or icon files. */ +/** Validated locale display fields and diagnostics; bundles may also use exported manifests and icons. */ export interface PluginLocalizedMeta { /** Display title; omission preserves the consumer's technical-name fallback. */ readonly title?: LocalizedText - /** Display introduction after locale and package-field fallback. */ + /** Display introduction; only bundles use package-field fallback. */ readonly description?: LocalizedText - /** Base64 image data URL read from the manifest's icon file; render as an image, not inline markup. */ + /** Bundle-only base64 image data URL from the ./icon resource; render as an image, not inline markup. */ readonly icon?: string /** Unmodified local metadata diagnostic; the plugin remains manageable. */ readonly error?: string diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 417430f817..637313dec1 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -255,12 +255,20 @@ describe('dsh family version coherence', () => { }) describe('package payload constraints', () => { - it.each(['./art/icon.svg', 'art/icon.svg'])('includes declared icon %s in the canonical payload', (icon) => { - expect(expectedDshPackageFiles({ icon, exports: { './locale/*.json': './locale/*.json' } })).toEqual([ - 'art/icon.svg', 'locale/*.json', 'lib/index.js', 'lib/types/**/*.d.ts', + it.each([ + ['./art/icon.svg', ['art/icon.svg']], + [{ import: './art/icon.svg', default: './art/fallback.svg' }, ['art/icon.svg', 'art/fallback.svg']], + [['./art/icon.svg', './art/icon.svg'], ['art/icon.svg']], + ] as const)('includes exported bundle icon targets in the canonical payload: %j', (icon, expected) => { + expect(expectedDshPackageFiles({ dsh: { bundle: { patch: './cordis.patch.yml' } }, exports: { './icon': icon } })).toEqual([ + ...expected, 'lib/index.js', 'cordis.patch.yml', 'lib/types/**/*.d.ts', ]) }) + it('ignores ordinary plugin icon exports in the canonical payload', () => { + expect(expectedDshPackageFiles({ exports: { './icon': './icon.svg' } })).toEqual(['lib/index.js', 'lib/types/**/*.d.ts']) + }) + it('accepts the Agent Team icon payload and rejects its omission', () => { const dir = 'packages/experimental/agent-team-profile' const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index fee525c803..faa254fa8b 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -80,16 +80,7 @@ export interface PackageManifest { main?: string types?: string bin?: string | Record - exports?: Record< - string, - | string - | { - types?: string - default?: string - } - | null - | undefined - > + exports?: Record files?: string[] icon?: string publishConfig?: { access?: string } @@ -239,7 +230,7 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl } /** - * Compute canonical publication patterns, including the declared icon and exported locale JSON resources. + * Compute canonical publication patterns, including the exported bundle icon and exported locale JSON resources. * @param manifest - workspace package manifest. * @returns the icon and deduplicated locale targets followed by runtime and declaration payloads. */ @@ -256,8 +247,11 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...bundleFiles, ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), ] + const targets = (value: unknown): string[] => typeof value === 'string' ? [value] + : typeof value === 'object' && value !== null ? Object.values(value).flatMap(targets) : [] + const icons = bundle === undefined ? [] : targets(manifest.exports?.['./icon']) return [ - ...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [], + ...new Set(icons.filter(icon => icon.startsWith('./')).map(icon => icon.slice(2))), ...[...localeFiles].sort(), 'lib/index.js', // Packages with an invariant export publish its runtime as a separate @@ -300,6 +294,12 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ] } +/** Fields of a conditional export; scalar and array targets have no named conditions. */ +function exportFields(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record : undefined +} + /** Whether one conditional export exactly names the generated runtime and declaration pair. */ function hasExportPair( manifest: PackageManifest, @@ -307,19 +307,16 @@ function hasExportPair( types: string, runtime: string, ): boolean { - const entry = manifest.exports?.[subpath] - return typeof entry === 'object' - && entry !== null - && entry.types === types - && entry.default === runtime + const entry = exportFields(manifest.exports?.[subpath]) + return entry?.types === types && entry.default === runtime } /** Runtime target of an export entry: conditional `default`, or the bare-string shorthand. */ function exportDefault(manifest: PackageManifest, subpath: string): string | undefined { const entry = manifest.exports?.[subpath] if (typeof entry === 'string') return entry - if (typeof entry === 'object' && entry !== null) return entry.default - return undefined + const target = exportFields(entry)?.default + return typeof target === 'string' ? target : undefined } /** Whether any export's runtime default points into the tsc-emitted lib/types tree. */ @@ -479,16 +476,14 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st if (manifest.types !== 'lib/types/index.d.ts') { errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`) } - const rootExport = manifest.exports?.['.'] - const rootEntry = typeof rootExport === 'object' && rootExport !== null ? rootExport : undefined + const rootEntry = exportFields(manifest.exports?.['.']) if (rootEntry?.types !== './lib/types/index.d.ts') { errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`) } if (rootEntry?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) } - const invariantRaw = manifest.exports?.['./invariant'] - const invariantExport = typeof invariantRaw === 'object' && invariantRaw !== null ? invariantRaw : undefined + const invariantExport = exportFields(manifest.exports?.['./invariant']) if (invariantExport?.types !== undefined && invariantExport.types !== './lib/types/invariant.d.ts') { errors.push(`${label}: package.json exports["./invariant"].types must be "./lib/types/invariant.d.ts"`) } diff --git a/scripts/optional-bundles.spec.ts b/scripts/optional-bundles.spec.ts index 315c22352f..cac1877912 100644 --- a/scripts/optional-bundles.spec.ts +++ b/scripts/optional-bundles.spec.ts @@ -77,7 +77,7 @@ describe('optional bundles', () => { }) it.each(OPTIONAL_BUNDLES)('%s resolves a title, description, and icon in both shipped languages', (name) => { - const meta = readPluginMeta(name, pathToFileURL(`${bundle(name).dir}/package.json`).href) + const meta = readPluginMeta(name, pathToFileURL(`${bundle(name).dir}/package.json`).href, { bundleDirectory: bundle(name).dir }) expect(meta?.error).toBeUndefined() for (const field of [meta?.title, meta?.description]) { expect(typeof field).toBe('object') diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index bd03908d50..32d48c48c1 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -95,7 +95,7 @@ describe('default product isolation', () => { const root = fixture() const layer = '@deepseek-ai/dsh-experimental-layer' write(root, 'packages/experimental/layer/package.json', { - name: layer, icon: './icon.svg', exports: { './locale/*.json': './locale/*.json' }, + name: layer, exports: { './icon': './icon.svg', './locale/*.json': './locale/*.json' }, dependencies: { [experimental]: 'workspace:^' }, dsh: { bundle: { patch: './cordis.patch.yml' } }, }) write(root, 'packages/experimental/layer/cordis.patch.yml', [{ insert: [{ name: experimental }] }]) @@ -123,7 +123,7 @@ describe('default product isolation', () => { const failures = verifyDefaultProductIsolation(root).failures.join('\n') expect(failures).toContain(`optional bundle ${experimental} must be a runtime dependency of apps/cli`) expect(failures).toContain(`optional bundle ${experimental} must declare dsh.bundle.patch`) - expect(failures).toContain(`optional bundle ${experimental} must declare an icon`) + expect(failures).toContain(`optional bundle ${experimental} must export ./icon`) expect(failures).toContain(`optional bundle ${experimental} must export ./locale/*.json display metadata`) // An experimental runtime dependency the list does not name is still a product requirement. diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index 4f07d07cd8..9cef3e916f 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -94,8 +94,8 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes if (manifest?.dsh?.bundle?.patch === undefined) { failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must declare dsh.bundle.patch`) } - if (typeof manifest?.icon !== 'string') { - failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must declare an icon`) + if (manifest?.exports?.['./icon'] === undefined) { + failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must export ./icon`) } if (manifest?.exports?.['./locale/*.json'] === undefined) { failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must export ./locale/*.json display metadata`) diff --git a/scripts/verify-package-meta.spec.ts b/scripts/verify-package-meta.spec.ts index bc8bac269c..827810845b 100644 --- a/scripts/verify-package-meta.spec.ts +++ b/scripts/verify-package-meta.spec.ts @@ -1,6 +1,6 @@ -/** Bundle metadata checks use source-only packages and real resource exports. */ +/** Plugin metadata checks use private source-only packages and real resource exports. */ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' @@ -29,7 +29,6 @@ function manifest(fields: object = {}): void { name: '@test/plugin', description: 'Package description', type: 'module', - dsh: { bundle: { patch: './cordis.yml' } }, exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': './locale/*.json' }, files: ['lib', 'locale'], ...fields, @@ -40,165 +39,444 @@ it('rejects an empty workspace package corpus', () => { expect(packageMetaProblems(root).join('\n')).toContain('no workspace package manifests') }) -it.each([undefined, {}, { bundle: {} }, { profile: { bundles: [] } }])( - 'ignores ordinary package JSON display metadata: %j', (dsh) => { - manifest({ dsh, icon: './missing.svg', exports: { '.': './entry.js' }, files: [] }) - file('entry.js', "throw new Error('plugin entry executed')") - file('locale/en.json', '{') - json('locale/zh.json', { meta: { title: false } }) - expect(packageMetaProblems(root)).toEqual([]) - }, -) - -it('ignores exported ordinary plugin metadata within a bundle', () => { - manifest({ - exports: { - './package.json': './package.json', - './search': './entry.js', - './search/package.json': './search/package.json', - './search/locale/*.json': './search/locale/*.json', - }, - files: [], - }) - json('search/package.json', { icon: './missing.svg' }) - json('search/locale/en.json', { meta: { title: false } }) - json('locale/other/zh.json', { meta: { description: false } }) +it('does not require locale resources for exported package name and description', () => { + manifest({ exports: { '.': './lib/index.js', './package.json': './package.json' }, files: ['lib'] }) expect(packageMetaProblems(root)).toEqual([]) }) -it('does not require locales for bundle manifest name and description', () => { - manifest({ exports: { './package.json': './package.json' }, files: [] }) +it.each([undefined, ['art'], ['art/*.webp'], ['./art/icon.webp']])('accepts an exported bundle icon without other resources: %j', (files) => { + manifest({ dsh: { bundle: { patch: './cordis.patch.yml' } }, exports: { './icon': './art/icon.webp' }, files }) + file('art/icon.webp', 'image') expect(packageMetaProblems(root)).toEqual([]) }) -it.each([undefined, ['art'], ['art/*.svg'], ['./art/icon.svg']])('accepts a published bundle icon: %j', (files) => { - manifest({ icon: './art/icon.svg', files }) - file('art/icon.svg', '') - expect(packageMetaProblems(root)).toEqual([]) +it.each([['lib'], ['art', '!art/icon.webp']])('rejects a bundle icon omitted from publication: %j', (...files) => { + manifest({ dsh: { bundle: { patch: './cordis.patch.yml' } }, exports: { './icon': './art/icon.webp' }, files }) + file('art/icon.webp', 'image') + expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.webp') }) -it.each([['lib'], ['art', '!art/icon.svg']])('rejects an unpublished bundle icon: %j', (...files) => { - manifest({ icon: './art/icon.svg', files }) - file('art/icon.svg', '') - expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.svg') -}) - -it.each([null, false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates bundle icons: %j', (icon) => { - manifest({ icon }) - file('../outside.svg', '') +it.each([false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates bundle icon targets: %j', (icon) => { + manifest({ dsh: { bundle: { patch: './cordis.patch.yml' } }, exports: { './icon': icon } }) expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:') }) -it('requires the bundle icon declaration to be exported', () => { - manifest({ icon: './icon.svg', exports: { '.': './entry.js' }, files: ['icon.svg'] }) - file('icon.svg', '') - expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration') +it.each([['assets'], ['lib']])('checks publication of the Node-selected conditional bundle icon: %j', (...files) => { + manifest({ dsh: { bundle: { patch: './cordis.patch.yml' } }, exports: { './icon': { import: './assets/logo.webp', default: './missing.gif' } }, files }) + file('assets/logo.webp', 'image') + const problems = packageMetaProblems(root) + if (files.includes('assets')) expect(problems).toEqual([]) + else expect(problems.join('\n')).toContain('files must include assets/logo.webp') }) -it('reads bundle resources without evaluating entries or built output', () => { - manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } }) - file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('entry executed')") - json('locale/en.json', { meta: { title: 'Bundle', description: '%literal%' } }) - json('locale/zh.json', { meta: { title: '组合包' }, nested: [1, null] }) +it('ignores manifest fallback and icons for ordinary plugins', () => { + manifest({ exports: { './package.json': './broken.json', './icon': './missing.svg' } }) + file('broken.json', '{') + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('requires publication of a bundle fallback manifest', () => { + manifest({ dsh: { bundle: { patch: './cordis.patch.yml' } }, exports: { './package.json': './resources/display.json' }, files: ['lib'] }) + json('resources/display.json', { name: 'Display' }) + expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/display.json') +}) + +it('ignores icon-like fields in unrelated source JSON', () => { + manifest() + json('data.json', { icon: false }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('excludes tests, installed dependencies, and build output from metadata discovery', () => { + manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] }) + json('tests/fixtures/locale/en.json', { meta: { title: false } }) + json('node_modules/dependency/locale/en.json', { meta: { title: false } }) + file('lib/locale/en.json', '{') + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('ignores unrelated locale content without requiring metadata exports', () => { + manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] }) + json('locale/zh.json', { buttons: { save: '保存' }, enabled: true, other: null }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('ignores ordinary JSON metadata outside locale directories and resource exports', () => { + manifest({ exports: { '.': './lib/index.js', './data.json': './data.json' } }) + json('data.json', { meta: { title: false, schemaVersion: 1 } }) + json('resources/search/en.json', { meta: { description: 'Business data' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('accepts direct root metadata and per-field English fallback without built entries', () => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin', description: '%literal%' }, buttons: { save: 'Save' } }) + json('locale/zh.json', { meta: { title: '插件' }, nested: [1, null] }) + json('locale/pt-BR.json', { meta: { description: 'Descrição' } }) + file('locale/README.txt', 'Not a JSON resource.') expect(packageMetaProblems(root)).toEqual([]) - expect(existsSync(join(dir, 'executed'))).toBe(false) expect(existsSync(join(dir, 'lib'))).toBe(false) }) -it.each(['lib', 'tests'])('rejects bundle resources outside source discovery: %s', (directory) => { - manifest({ icon: './missing.svg', exports: { './package.json': './package.json', './locale/en.json': `./${directory}/en.json` } }) - file(`${directory}/en.json`, '{') +it.each(['en', 'zh'])('rejects %s metadata resolved into build output without reading that JSON', (language) => { + manifest({ + exports: { + './locale/*.json': './locale/*.json', + [`./locale/${language}.json`]: `./lib/locale/${language}.json`, + }, + }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + file(`lib/locale/${language}.json`, '{') const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain(`${language}.json`) expect(problems).toContain('source JSON') expect(problems).not.toContain('Plugin metadata for') }) -it.each([ - './resources/*.json', - { types: './missing/*.json', import: './resources/*.json', require: './missing/*.json' }, - { node: { import: './resources/*.json' }, default: './missing/*.json' }, - [null, './resources/*.json'], -])('accepts Node-selected bundle resource exports: %j', (target) => { - manifest({ exports: { './locale/*.json': target }, files: ['resources'] }) - json('resources/en.json', { meta: { title: 'Bundle' } }) - json('resources/zh.json', { meta: { title: '组合包' } }) +it('does not read built locale JSON while checking a source icon declaration', () => { + manifest({ + dsh: { bundle: { patch: './cordis.patch.yml' } }, files: ['icon.svg'], + exports: { './package.json': './package.json', './locale/en.json': './lib/locale/en.json' }, + }) + file('icon.svg', '') + file('lib/locale/en.json', '{') + json('locale/en.json', { meta: { title: 'Plugin' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('exports must expose locale/en.json') + expect(problems).not.toContain('Plugin metadata for') +}) + +it.each(['lib', 'tests'])('rejects an icon declaration when its only locale is excluded source: %s', (directory) => { + manifest({ + dsh: { bundle: { patch: './cordis.patch.yml' } }, files: ['lib'], + exports: { './package.json': './package.json', './locale/en.json': `./${directory}/locale/en.json` }, + }) + file(`${directory}/locale/en.json`, '{}') + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('@test/plugin: bundle metadata requires locale resources to resolve to source JSON') + expect(problems).not.toContain('Plugin metadata for') +}) + +it('reads static metadata without evaluating the plugin entry', () => { + manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } }) + file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('plugin entry executed')") + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root)).toEqual([]) + expect(existsSync(join(dir, 'executed'))).toBe(false) +}) + +it('accepts metadata from an unscoped package self-reference', () => { + manifest({ name: 'plugin' }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('validates root and multiple exported plugins independently', () => { + manifest({ + exports: { + '.': './lib/index.js', + './search': './lib/search.js', + './write': './lib/write.js', + './locale/*.json': './locale/root/*.json', + './search/locale/*.json': './locale/search/*.json', + './write/locale/*.json': './locale/write/*.json', + }, + }) + json('locale/root/en.json', { meta: { title: 'Bundle' } }) + json('locale/search/en.json', { meta: { title: 'Search' } }) + json('locale/search/zh.json', { meta: { title: '搜索' } }) + json('locale/write/en.json', { meta: { description: 'Write files' } }) + json('locale/write/zh.json', { meta: { description: '写入文件' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('accepts missing English fields in independently exported plugin metadata', () => { + manifest({ + exports: { + '.': './lib/index.js', + './search': './lib/search.js', + './locale/*.json': './locale/root/*.json', + './search/locale/*.json': './locale/search/*.json', + }, + }) + json('locale/root/en.json', { meta: { description: 'Bundle description' } }) + json('locale/search/en.json', { meta: { title: 'Search' } }) + json('locale/search/zh.json', { meta: { description: '搜索文件' } }) expect(packageMetaProblems(root)).toEqual([]) }) it.each([ { '.': './lib/index.js' }, - { './locale/*.json': null }, - { './locale/*.json': { require: './locale/*.json' } }, - { './locale/*.json': './locale/*.json', './locale/en.json': null }, - { './locale/*.json': ['./missing/*.json', './locale/*.json'] }, -])('rejects inaccessible root bundle metadata: %j', (exports) => { - manifest({ exports }) - json('locale/en.json', { meta: { title: 'Bundle' } }) - expect(packageMetaProblems(root).join('\n')).toContain('en.json') + { '.': './lib/index.js', './locale/*.json': null }, + { '.': './lib/index.js', './locale/*.json': { require: './locale/*.json' } }, + { '.': './lib/index.js', './locale/*.json': './locale/*.json', './locale/en.json': null }, +])('rejects a locale file that package exports do not expose: %j', (exports) => { + manifest({ exports: { ...exports, './package.json': './package.json' } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('locale/en.json') + expect(problems).toContain('exports') }) -it.each(['locale', 'resources'])('requires an English discovery resource for translated bundle metadata in %s', (directory) => { - manifest({ exports: { './locale/*.json': `./${directory}/*.json` } }) - json(`${directory}/zh.json`, { meta: { title: '组合包' } }) - expect(packageMetaProblems(root).join('\n')).toContain('en.json') +it('requires exports for every translated resource', () => { + manifest({ exports: { '.': './lib/index.js', './locale/en.json': './locale/en.json' } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('locale/zh.json') + expect(problems).toContain('exports') }) -it('rejects inaccessible bundle metadata outside the conventional locale directory', () => { - manifest({ exports: { './locale/*.json': { require: './resources/*.json' } } }) - json('resources/en.json', { meta: { title: 'Bundle' } }) - expect(packageMetaProblems(root).join('\n')).toContain('en.json') +it.each(['locale/search', 'search/locale'])('discovers child metadata without its resource export: %s', (directory) => { + manifest({ exports: { './search': './lib/search.js' }, files: [directory] }) + json(`${directory}/en.json`, { meta: { title: 'Search' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain(`${directory}/en.json`) + expect(problems).toContain('exports') }) -it('discovers bundle locales through a general export wildcard without claiming child locales', () => { - manifest({ exports: { './*': './resources/*' }, files: ['resources/locale'] }) - json('resources/locale/en.json', { meta: { title: 'Bundle' } }) - json('resources/search/locale/en.json', { meta: { title: false } }) +it('rejects an inaccessible resource declared outside a locale directory', () => { + manifest({ + exports: { './search/locale/*.json': { require: './resources/search/*.json' } }, + files: ['resources/search'], + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('resources/search/en.json') + expect(problems).toContain('exports') +}) + +it.each([ + './resources/search/*.json', + { types: './missing/*.json', import: './resources/search/*.json', require: './missing/*.json' }, + { node: { import: './resources/search/*.json' }, default: './missing/*.json' }, + ['./resources/search/*.json'], +])('accepts resource exports to an independent physical directory: %j', (target) => { + manifest({ + exports: { './search': './lib/search.js', './search/locale/*.json': target }, + files: ['resources/search'], + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + json('resources/search/zh.json', { meta: { title: '搜索' } }) expect(packageMetaProblems(root)).toEqual([]) }) -it('requires exports and publication for each bundle translation', () => { - manifest({ exports: { './locale/en.json': './locale/en.json' }, files: ['locale/en.json'] }) - json('locale/en.json', { meta: { title: 'Bundle' } }) - json('locale/zh.json', { meta: { title: '组合包' } }) +it('accepts individually exported resources in an independent physical directory', () => { + manifest({ + exports: { + './search': './lib/search.js', + './search/locale/en.json': './resources/search/en.json', + './search/locale/zh.json': './resources/search/zh.json', + }, + files: ['resources'], + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + json('resources/search/zh.json', { meta: { title: '搜索' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('ignores metadata in an unselected exports condition', () => { + manifest({ + exports: { './locale/*.json': { import: './esm/*.json', require: './cjs/*.json' } }, + files: ['esm'], + }) + json('esm/en.json', { meta: { title: 'ESM plugin' } }) + json('cjs/en.json', { meta: { title: false } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([ + { default: './locale/*.json', import: './missing/*.json' }, + [null, './locale/*.json'], + ['../invalid/*.json', './locale/*.json'], +])('uses Node condition order and export-array fallback: %j', (target) => { + manifest({ exports: { './locale/*.json': target } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('does not fall through a valid exports target just because its file is missing', () => { + manifest({ exports: { './locale/*.json': ['./missing/*.json', './locale/*.json'] } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root).join('\n')).toContain('locale/en.json') +}) + +it('discovers root and child locale resources through a general export wildcard', () => { + manifest({ exports: { './*': './resources/*' }, files: ['resources'] }) + json('resources/locale/en.json', { meta: { title: 'Bundle' } }) + json('resources/search/locale/en.json', { meta: { title: 'Search' } }) + json('resources/review/locale/en.json', { meta: { title: 'Review' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('rejects an exact resource exclusion under a general export wildcard', () => { + manifest({ exports: { './*': './resources/*', './search/locale/en.json': null }, files: ['resources'] }) + json('resources/locale/en.json', { meta: { title: 'Bundle' } }) + json('resources/search/locale/en.json', { meta: { title: 'Search' } }) const problems = packageMetaProblems(root).join('\n') - expect(problems).toContain('exports must expose @test/plugin/locale/zh.json') - expect(problems).toContain('files must include locale/zh.json') + expect(problems).toContain('search/locale/en.json') + expect(problems).toContain('exports') }) -it('rejects bundle translations mapped outside the English directory', () => { - manifest({ exports: { './locale/en.json': './locale/en.json', './locale/zh.json': './elsewhere/zh.json' } }) - json('locale/en.json', { meta: { title: 'Bundle' } }) - json('elsewhere/zh.json', { meta: { title: '组合包' } }) - expect(packageMetaProblems(root).join('\n')).toContain('must share the English locale directory') +it('discovers plugin wildcards with repeated target substitutions', () => { + manifest({ exports: { './*/locale/en.json': './resources/*/copy-*/en.json' }, files: ['resources'] }) + json('resources/search/copy-search/en.json', { meta: { title: 'Search' } }) + json('resources/review/copy-review/en.json', { meta: { title: 'Review' } }) + expect(packageMetaProblems(root)).toEqual([]) }) -it.each([null, [], false, 1, 'text'])('rejects malformed bundle locale metadata: %j', (meta) => { +it('rejects files that do not share the repeated export-target substitution', () => { + manifest({ exports: { './*/locale/en.json': './locale/*/copy-*/en.json' }, files: ['locale'] }) + json('locale/search/copy-other/en.json', { meta: { title: 'Search' } }) + expect(packageMetaProblems(root).join('\n')).toContain('locale/search/copy-other/en.json') +}) + +it('rejects a missing exported locale target even when a conventional locale file exists', () => { + manifest({ exports: { '.': './lib/index.js', './locale/*.json': './missing/*.json' } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root).join('\n')).toContain('en.json') +}) + +it('rejects language resources mapped outside the English resource directory', () => { + manifest({ + exports: { + '.': './lib/index.js', + './locale/en.json': './locale/en.json', + './locale/zh.json': './elsewhere/zh.json', + }, + files: ['locale', 'elsewhere'], + }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + json('elsewhere/zh.json', { meta: { title: '其他' } }) + expect(packageMetaProblems(root).join('\n')).toContain('zh.json') +}) + +it.each([undefined, ['.'], ['./'], ['locale'], ['locale/'], ['./locale'], ['locale/*.json'], ['locale/**'], + ['locale/en.json', 'locale/zh.json']])( + 'accepts published locale resources: %j', + (files) => { + manifest({ files }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + expect(packageMetaProblems(root)).toEqual([]) + }, +) + +it.each([ + [[], 'en'], + [['lib'], 'en'], + [['locale/en.json'], 'zh'], + [['locale', '!locale/en.json'], 'en'], + [['locale', '!locale/*.json'], 'en'], + [['locale', '!locale'], 'en'], +] as const)('rejects omitted or excluded publication files: %j', (files, language) => { + manifest({ files }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + expect(packageMetaProblems(root).join('\n')).toContain(`files must include locale/${language}.json`) +}) + +it.each([{ files: ['locale'] }, { files: ['resources', '!resources/search/zh.json'] }])( + 'checks publication paths after resolving resource exports: %j', + ({ files }) => { + manifest({ + exports: { './search': './lib/search.js', './search/locale/*.json': './resources/search/*.json' }, + files, + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + json('resources/search/zh.json', { meta: { title: '搜索' } }) + expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search/') + }, +) + +it.each(['locale', 'resources/search'])('rejects translated metadata without an English resource: %s', (directory) => { + manifest({ + exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': `./${directory}/*.json` }, + files: [directory], + }) + json(`${directory}/zh.json`, { meta: { title: '插件' } }) + expect(packageMetaProblems(root).join('\n')).toContain('en.json') +}) + +it.each([ + { field: 'title', packageInfo: true }, + { field: 'description', packageInfo: true }, + { field: 'title', packageInfo: false }, + { field: 'description', packageInfo: false }, +])('accepts a missing English $field with package metadata $packageInfo', ({ field, packageInfo }) => { + manifest({ + exports: { + '.': './lib/index.js', + './locale/*.json': './locale/*.json', + ...packageInfo ? { './package.json': './package.json' } : {}, + }, + }) + json('locale/en.json', { meta: {} }) + json('locale/zh.json', { meta: { [field]: '译文' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([null, [], false, 1, 'text'])('rejects malformed meta: %j', (meta) => { manifest() json('locale/en.json', { meta }) - expect(packageMetaProblems(root).join('\n')).toContain('meta') + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('en.json') + expect(problems).toContain('meta') }) -it.each(['en', 'zh'])('rejects malformed bundle locale JSON: %s', (language) => { +it.each([null, [], false, 1, 'text'])('rejects a non-object English locale document: %j', (contents) => { manifest() - json('locale/en.json', { meta: { title: 'Bundle' } }) + json('locale/en.json', contents) + expect(packageMetaProblems(root).join('\n')).toContain('en.json') +}) + +it.each([null, false, 1, {}, [], '', ' '])('rejects a malformed display field: %j', (value) => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin', description: 'About it' } }) + json('locale/zh.json', { meta: { title: value } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('zh.json') + expect(problems).toContain('title') +}) + +it.each(['en', 'zh'])('rejects malformed JSON in %s.json', (language) => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) file(`locale/${language}.json`, '{') expect(packageMetaProblems(root).join('\n')).toContain(`${language}.json`) }) -it('ignores unrelated JSON and locale content without display metadata', () => { - manifest({ exports: { './package.json': './package.json' } }) - json('data.json', { meta: { title: false } }) - json('locale/zh.json', { buttons: { save: '保存' } }) - json('tests/locale/en.json', { meta: { title: false } }) +it('rejects invalid language filenames', () => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/not_a_language.json', { meta: { title: 'Other' } }) + expect(packageMetaProblems(root).join('\n')).toContain('not_a_language.json') +}) + +it('rejects case-equivalent language files when the filesystem can hold both', (context) => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/EN.json', { meta: { title: 'Other' } }) + if (readdirSync(join(dir, 'locale')).length !== 2) context.skip() + expect(packageMetaProblems(root).join('\n')).toContain('duplicates locale en') +}) + +it('accepts locale files without metadata alongside an English introduction', () => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { buttons: { save: '保存' }, other: null }) expect(packageMetaProblems(root)).toEqual([]) }) -it('reports invalid metadata across multiple bundles', () => { +it('reports metadata failures from more than one workspace package', () => { manifest() json('locale/en.json', { meta: { title: false } }) dir = join(root, 'packages', 'another', 'second') manifest({ name: '@test/second' }) - json('locale/en.json', { meta: { description: '' } }) + json('locale/en.json', { meta: { title: 'Second' } }) + json('locale/zh.json', { meta: { description: '' } }) const problems = packageMetaProblems(root).join('\n') expect(problems).toContain('@test/plugin') expect(problems).toContain('@test/second') diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts index 1773100c2b..926ce04183 100644 --- a/scripts/verify-package-meta.ts +++ b/scripts/verify-package-meta.ts @@ -1,4 +1,4 @@ -/** Validate bundle display metadata, Node resource exports, and package publication selections. */ +/** Validate source locale metadata, Node resource exports, and package publication selections. */ import { globSync, readFileSync, realpathSync } from 'node:fs' import { basename, dirname, join, matchesGlob, relative, resolve } from 'node:path' @@ -7,10 +7,9 @@ import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot interface Manifest { name: string - files?: string[] exports?: unknown - icon?: unknown - dsh?: { bundle?: { patch?: unknown } } + files?: string[] + dsh?: { bundle?: { patch?: string } } } interface SourceJson { @@ -41,18 +40,19 @@ function targetsOf(value: unknown): string[] { return Object.values(value).flatMap(targetsOf) } -function rootLocaleTarget(file: string, exports: unknown): boolean { - if (typeof exports !== 'object' || exports === null) return false - return Object.entries(exports).some(([key, target]) => targetsOf(target).some((pattern) => { - const index = pattern.indexOf('*') - if (index < 0) return pattern === `./${file}` && /^\.\/locale\/[^/]+\.json$/u.test(key) - const count = pattern.split('*').length - 1 - const source = `./${file}` - const width = (source.length - pattern.length + count) / count - if (width < 0 || !Number.isInteger(width)) return false - const value = source.slice(index, index + width) - return pattern.replaceAll('*', value) === source && /^\.\/locale\/[^/]+\.json$/u.test(key.replaceAll('*', value)) - })) +// Target matching discovers resource requests; Node selects conditions, arrays, and export precedence. +function substitution(pattern: string, file: string): string | undefined { + const index = pattern.indexOf('*') + if (index < 0) return pattern === file ? '' : undefined + const count = pattern.split('*').length - 1 + const width = (file.length - pattern.length + count) / count + if (width < 0 || !Number.isInteger(width)) return undefined + const value = file.slice(index, index + width) + return pattern.replaceAll('*', value) === file ? value : undefined +} + +function pluginOf(resource: string): string | undefined { + return /^(\..*)\/locale\/[^/]+\.json$/u.exec(resource)?.[1] } function published(file: string, files: string[]): boolean { @@ -69,73 +69,132 @@ function packageProblems(manifestPath: string): string[] { const problems: string[] = [] const dir = realpathSync(dirname(manifestPath)) const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest - if (pkg.dsh?.bundle?.patch === undefined) return problems const documents = sourceJsonFiles(dir) const byPath = new Map(documents.map(document => [join(dir, document.file), document])) const parentURL = pathToFileURL(join(dir, 'package.json')).href + const specifierOf = (plugin: string): string => pkg.name + (plugin === '.' ? '' : plugin.slice(1)) + const candidates = new Set(['.']) + const alternatives = new Map>() + const exported = typeof pkg.exports === 'object' && pkg.exports !== null ? Object.entries(pkg.exports) : [] + for (const [key, target] of exported) { + if (!key.startsWith('.')) continue + if (!key.includes('*')) { + candidates.add(key) + if (key.endsWith('/package.json')) candidates.add(key.slice(0, -'/package.json'.length)) + } + const plugin = pluginOf(key) + if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin) + for (const pattern of targetsOf(target)) { + for (const document of documents) { + const value = substitution(pattern, `./${document.file}`) + if (value === undefined) continue + const request = key.replaceAll('*', value) + if (request.endsWith('/package.json')) candidates.add(request.slice(0, -'/package.json'.length)) + const owner = pluginOf(request) + if (owner === undefined) continue + candidates.add(owner) + let requests = alternatives.get(document.file) + if (requests === undefined) alternatives.set(document.file, requests = new Set()) + requests.add(pkg.name + request.slice(1)) + } + } + } + + const resolved = new Map() const lookup = (resource: string): string | undefined => { - try { - return resolvePluginResource(resource, parentURL) - } catch (_error) { - // Missing exports and files are diagnosed against their source metadata below. - return undefined + if (!resolved.has(resource)) { + try { + resolved.set(resource, resolvePluginResource(resource, parentURL)) + } catch (_error) { + // Missing exports and files are diagnosed against their source metadata below. + resolved.set(resource, undefined) + } } + return resolved.get(resource) } - const resourceOf = (filename: string): string => `${pkg.name}/locale/${filename}` const filenames = new Set(['en.json', ...documents.map(document => basename(document.file))]) - const resources = [...filenames].flatMap((filename) => { - const file = lookup(resourceOf(filename)) - return file === undefined ? [] : [{ filename, file }] - }) - const manifest = lookup(`${pkg.name}/package.json`) - if (pkg.icon !== undefined && manifest !== join(dir, 'package.json')) { - problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`) - } - const english = lookup(resourceOf('en.json')) - const directory = english === undefined ? join(dir, 'locale') : dirname(english) - const localeDocuments = documents.filter(document => dirname(join(dir, document.file)) === directory - || (english === undefined && rootLocaleTarget(document.file, pkg.exports))) - if (english === undefined && localeDocuments.some(document => document.metadata || document.invalid)) { - problems.push(`${manifestPath}: exports must provide ${resourceOf('en.json')} as the locale discovery baseline`) - } - let sourceResources = true - for (const { filename, file } of resources) { - if (!byPath.has(file)) { - problems.push(`${resourceOf(filename)}: locale metadata must resolve to source JSON, received ${file}`) - sourceResources = false + const claimed = new Set() + for (const candidate of candidates) { + const specifier = specifierOf(candidate) + const resourceOf = (filename: string): string => `${specifier}/locale/${filename}` + const resources = [...filenames].flatMap((filename) => { + const file = lookup(resourceOf(filename)) + return file === undefined ? [] : [{ filename, file }] + }) + if (candidate === '.' && pkg.dsh?.bundle?.patch !== undefined) { + if (resources.every(({ file }) => byPath.has(file))) { + const meta = readPluginMeta(specifier, parentURL, { bundleDirectory: dir }) + if (meta?.error !== undefined) problems.push(meta.error) + for (const resource of ['package.json', 'icon']) { + const target = lookup(`${specifier}/${resource}`) + if (target === undefined) continue + const file = relative(dir, target).replaceAll('\\', '/') + if (file !== 'package.json' && pkg.files !== undefined && !published(file, pkg.files)) { + problems.push(`${manifestPath}: files must include ${file}`) + } + } + } else { + problems.push(`${specifier}: bundle metadata requires locale resources to resolve to source JSON`) + } } - if (dirname(file) !== directory) { - problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`) + if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue + for (const { file } of resources) claimed.add(file) + const english = lookup(resourceOf('en.json')) + if (english === undefined) { + problems.push(`${manifestPath}: exports must provide ${resourceOf('en.json')} as the locale discovery baseline`) + continue + } + if (!byPath.has(english)) { + problems.push(`${resourceOf('en.json')}: English metadata must resolve to source JSON, received ${english}`) + continue + } + const directory = dirname(english) + let sourceResources = true + for (const { filename, file } of resources) { + if (!byPath.has(file)) { + problems.push(`${resourceOf(filename)}: locale metadata must resolve to source JSON, received ${file}`) + sourceResources = false + } + if (dirname(file) !== directory) { + problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`) + } + } + if (sourceResources) { + const meta = readPluginMeta(specifier, parentURL) + if (meta?.error !== undefined) problems.push(meta.error) + } + for (const document of documents.filter(document => dirname(join(dir, document.file)) === directory)) { + const resource = resourceOf(basename(document.file)) + const file = lookup(resource) + if (file === undefined) { + problems.push(`${manifestPath}: exports must expose ${resource} (${document.file})`) + } else if (file !== join(dir, document.file)) { + problems.push(`${manifestPath}: exports for ${resource} must resolve to ${document.file}, received ${relative(dir, file)}`) + } + if (pkg.files !== undefined && !published(document.file, pkg.files)) { + problems.push(`${manifestPath}: files must include ${document.file}`) + } } } - if (sourceResources) { - const meta = readPluginMeta(pkg.name, parentURL) - if (meta?.error !== undefined) problems.push(meta.error) - if (meta?.icon !== undefined && typeof pkg.icon === 'string') { - const iconFile = relative(dir, resolve(dir, pkg.icon)).replaceAll('\\', '/') - if (pkg.files !== undefined && !published(iconFile, pkg.files)) problems.push(`${manifestPath}: files must include ${iconFile}`) - } - } - for (const document of localeDocuments) { - if (english === undefined && !document.metadata && !document.invalid) continue - const resource = resourceOf(basename(document.file)) - const file = lookup(resource) - if (file === undefined) { - problems.push(`${manifestPath}: exports must expose ${resource} (${document.file})`) - } else if (file !== join(dir, document.file)) { - problems.push(`${manifestPath}: exports for ${resource} must resolve to ${document.file}, received ${relative(dir, file)}`) - } - if (pkg.files !== undefined && !published(document.file, pkg.files)) { - problems.push(`${manifestPath}: files must include ${document.file}`) - } + + for (const document of documents) { + const requests = alternatives.get(document.file) + if (!document.file.split('/').includes('locale') && requests === undefined) continue + if (!document.metadata && !document.invalid) continue + if (claimed.has(join(dir, document.file))) continue + if (requests !== undefined && [...requests].some((resource) => { + const selected = lookup(resource) + return selected !== undefined && byPath.has(selected) + })) continue + problems.push(`${manifestPath}: exports must expose ${document.file} as a plugin locale resource with an en.json discovery baseline`) } return problems } /** - * Check bundle display resources without evaluating entries or requiring ordinary plugin JSON metadata. + * Check static plugin locale metadata and resource publication without evaluating plugin entries or reading built output. * @param root - repository or fixture root. - * @returns diagnostics for an empty package corpus, invalid bundle metadata, inaccessible resources, or omitted publication files. + * @returns diagnostics for an empty package corpus, invalid metadata, inaccessible resources, or omitted publication files. */ export function packageMetaProblems(root: string): string[] { const manifests = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.replaceAll('\\', '/')) @@ -149,6 +208,6 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath( console.error(problems.join('\n')) process.exitCode = 1 } else { - console.log('verify-package-meta: bundle metadata, resource exports, and publication files are valid.') + console.log('verify-package-meta: plugin locale metadata, resource exports, and publication files are valid.') } } diff --git a/vendor/README.md b/vendor/README.md index 18e6b5817e..cfe68fc15e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -56,8 +56,6 @@ Keep this log exhaustive — every divergence from upstream must be listed. 22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through the fiber's `internal/config` hook and `resolveConfig`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite. -23. **Loaded plugin inspection in Cordis and Loader**: Cordis Fiber retains the original plugin supplied to Registry registration. Loader's `Entry.plugin` exposes its current live fiber's unwrapped plugin, including after HMR replacement or rollback, allowing display metadata inspection without importing disabled entries. Covered by inventory lifecycle and HMR tests. - ## Sync procedure To update a vendored package from upstream: diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index c57711a599..6bb1caa7a5 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -225,8 +225,6 @@ export class Fiber { public inject: Dict, public runtime: Plugin.Runtime | null, getOuterStack: () => string[], - /** Original plugin object for this fiber, including module namespace exports. */ - public readonly plugin?: Plugin, ) { this._config = config const collect = (dispose: Disposable) => { diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index fcb86ccbca..478cf036cb 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -327,7 +327,7 @@ export class RegistryService { this._internal.set(callback, runtime) } - const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack, plugin) + const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack) const wrapped = Object.create(fiber) as Fiber & PromiseLike wrapped.then = (onFulfilled, onRejected) => { return fiber.await().then(onFulfilled, onRejected) diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index cce439cf7c..8ab9f8e6a0 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,4 +1,4 @@ -import { Context, Fiber, FiberState, Inject, resolveConfig, type Plugin } from '@deepseek-ai/cordis' +import { Context, Fiber, FiberState, Inject, resolveConfig } from '@deepseek-ai/cordis' import { deepEqual, isNullable, updateVolatile, volatileEntries, type Volatile } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' @@ -45,11 +45,6 @@ export class Entry { public ctx: Context public fiber?: Fiber - - /** Already-loaded plugin; absent when the root fiber is absent or disposed. */ - get plugin(): Plugin | undefined { - return this.fiber?.uid ? this.fiber.plugin : undefined - } public parent!: EntryGroup // safety: call `entry.update()` immediately after creating an entry public options = {} as EntryOptions