diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index f5bbcd548c..6659b288a2 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: e98476b16ce9b978 - zh: 6c6b18c78c1d1a5f + en: 459beea3f3d82cd4 + zh: a7b8a2d1ab23fb3e diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 58c445cb6f..0e596b47d3 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -12,9 +12,9 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo.official` records catalog membership independently of installation. `availability` identifies a readable installation-provided or profile package, or `missing`; `installed` records the profile dependency declaration. An on-demand `installTarget` carries the host-derived exact spec and version. `BundleInfo` also carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. -`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. +`InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. `PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 012c7815bf..bca864a2fc 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -12,9 +12,9 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo.official` 记录与安装状态独立的目录身份。`availability` 表示可读取的安装随附包、profile 包或 `missing`;`installed` 记录 profile 的依赖声明。按需条目的 `installTarget` 包含 Host 生成的精确 spec 和版本。`BundleInfo` 还包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 -`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 +`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 `PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml new file mode 100644 index 0000000000..9ef7e7137b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md +/: + en: 4aa08ae3bd14c4ab + zh: 2eaddf4dbd941979 +/native-subagent-bundles-include-preset-tools: + en: b5c79b41fc34574a + zh: 5337c7120bf98f08 +/native-subagent-bundles-include-preset-tools/change: + en: 3f7ebbfda584459e + zh: dc573a9e30d98412 +/native-subagent-bundles-include-preset-tools/migration: + en: 5bf3e8bf29fe2d11 + zh: 723c6eac7bbc1833 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md new file mode 100644 index 0000000000..4053e8ca33 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Claude Code and Codex bundles add delegation tools to full Web presets and require those preset targets." +--- + +# Native subagent bundles include preset tools + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-subagent-claude-code` and `@deepseek-ai/dsh-subagent-codex` bundle layers register their providers and add delegation tools to `preset-standard`, `preset-cordis`, and `preset-ptc`. Previously they registered only providers. Selecting these bundles now requires those preset rows; headless, SDK, and ACP profiles do not contain them. + +The full Web presets omit their dormant `tool-subagent-claude-code` and `tool-subagent-codex` rows. The bundles contribute `optional-tool-subagent-claude-code` and `optional-tool-subagent-codex` instead. Provider configuration and native authentication are unchanged. + +## Migration + +1. For Web or Desktop, enable the desired provider in Plugins → Official. The ordinary installer selects the exact running DSH version. Remove manual rows that already register the same `subagent_claude_code` or `subagent_codex` tool before enabling the bundle. +2. Remove profile patches targeting the former dormant row ids. Configure the new rows with a scoped `preset` patch, using the appropriate `optional-tool-subagent-*` id. A configuration override still replaces the complete row configuration. +3. For headless, SDK, ACP, or other profiles without the three preset targets, retain the provider package in `$DSH_HOME/profiles//package.json` dependencies and remove its entry from `dsh.profile.bundles`. Mount the provider and desired generic delegation tool explicitly in that profile's `cordis.patch.yml`; the [Codex](../../../../packages/subagent/subagent-codex/README.md#exposing-the-tool) and [Claude Code](../../../../packages/subagent/subagent-claude-code/README.md#exposing-the-tool) examples show the rows. This preserves the provider-only installation workflow. +4. Open a new Session with the intended preset and confirm its tool catalog includes the selected delegation tool. The minimal preset remains unchanged. Off deselects the bundle without uninstalling it; Remove remains a separate action. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md new file mode 100644 index 0000000000..6bf4e98815 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Claude Code 和 Codex bundle 为完整 Web 预设添加委派工具,并要求这些预设目标存在。" +--- + +# 原生子智能体 bundle 包含预设工具 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-subagent-claude-code` 和 `@deepseek-ai/dsh-subagent-codex` 的 bundle 层会注册提供方,并为 `preset-standard`、`preset-cordis` 和 `preset-ptc` 添加委派工具。此前它们只注册提供方。现在选择这些 bundle 时要求上述预设行存在;headless、SDK 和 ACP 配置不包含这些行。 + +完整 Web 预设不再包含休眠的 `tool-subagent-claude-code` 和 `tool-subagent-codex` 行。Bundle 改为添加 `optional-tool-subagent-claude-code` 和 `optional-tool-subagent-codex`。提供方配置和原生认证保持不变。 + +## 迁移 + +1. 对于 Web 或 Desktop,在插件 → 官方中启用所需提供方。普通安装器会选择当前运行的 DSH 精确版本。启用 bundle 前,移除已注册同名 `subagent_claude_code` 或 `subagent_codex` 工具的手动配置行。 +2. 移除针对原有休眠行 id 的配置补丁。使用作用于预设的 `preset` 补丁和相应 `optional-tool-subagent-*` id 配置新行。配置覆盖仍会替换整行的配置。 +3. 对于 headless、SDK、ACP 或其他不包含这三个预设目标的配置,保留 `$DSH_HOME/profiles//package.json` 依赖中的提供方包,并从 `dsh.profile.bundles` 移除该条目。在该配置的 `cordis.patch.yml` 中显式挂载提供方和所需的通用委派工具;[Codex](../../../../packages/subagent/subagent-codex/README.zh.md#exposing-the-tool) 和 [Claude Code](../../../../packages/subagent/subagent-claude-code/README.zh.md#exposing-the-tool) 示例展示了相关行。这样可以保留仅安装提供方的工作流。 +4. 使用目标预设打开新会话,确认工具目录包含所选委派工具。minimal 预设保持不变。关闭只取消选择 bundle 而不卸载;移除仍为独立操作。 diff --git a/package.json b/package.json index ead6a0e6c7..3b7a9e5720 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --config-loader native --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", + "build:lib:host": "pnpm run gen-official-bundle-catalog && node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --config-loader native --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --config-loader native --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", @@ -205,6 +205,8 @@ "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs", + "gen-official-bundle-catalog": "tsx scripts/gen-official-bundle-catalog.ts", + "verify-official-bundle-catalog": "tsx scripts/gen-official-bundle-catalog.ts --check", "verify-product-use": "tsx scripts/verify-product-use.ts" }, "devDependencies": { diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 8d3808de73..03f3476b9d 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: 3538aeac1a9f8c87 - zh: 32c75392227be0e0 + en: 531a97b4e9717682 + zh: 0516f143c94c3d87 /deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures: en: b2e5cfdf8847d214 zh: 8d65771a3bd8a7c9 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 718aec65ff..c12140537b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -103,6 +103,8 @@ The projector preserves native omission behavior by checking literal defaults ag Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. 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. For a package-root specifier, missing locale fields fall back to the accessible `package.json`; a subpath specifier never reads a `package.json`. Malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads an image data URL, even when locale text is complete: a package root uses its manifest `icon`, or `/icon` when that field is omitted; a subpath uses `/icon`. 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. +`ON_DEMAND_BUNDLES` names the public Official packages installed only on request. `OFFICIAL_ON_DEMAND_CATALOG` embeds their package-owned localized metadata and icons for offline discovery; build-time generation checks resource completeness and freshness without importing provider code. The catalog contains no version: management derives the target from `getDshRuntimeVersion()` and uses the ordinary bundle installer. + ### Startup and reload failures Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Active root rows that disappear or become literally disabled release their resources before replacement rows activate, so a singleton provider can drain in-flight work before its successor registers. Compilation and disposal checks use the same parsed root entries for each reconciliation. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 2ab3e36b87..05e25b0f22 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -103,6 +103,8 @@ profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch 使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。包根标识缺失的 locale 字段回退到可访问的 `package.json`;子路径标识从不读取 `package.json`。格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会加载图片 data URL:包根使用清单 `icon`,省略该字段时使用 `<包名>/icon`;子路径使用 `<标识>/icon`。图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 +`ON_DEMAND_BUNDLES` 列出仅按需安装的官方公开包。`OFFICIAL_ON_DEMAND_CATALOG` 内嵌这些包拥有的本地化元信息和图标,支持离线发现;构建时生成器检查资源完整性与新鲜度,不导入提供者代码。目录不保存版本;管理器通过 `getDshRuntimeVersion()` 确定目标版本,并使用普通组合包安装器。 + ### 启动与重载失败 profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。消失或被字面量禁用的活跃根条目先释放资源,再激活替代条目,因此单实例提供方可以先排空进行中的工作,再让后继者注册。每次重组时,编译与释放检查使用同一份已解析的根条目。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index cf47d03755..38abd1f256 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -29,6 +29,7 @@ import { applyProfilePatches, compileProfilePatches, profilePatchPreset, prepare export { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch, type ProfilePatchWarning } from './profile-patches.ts' export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts' export { readPluginMeta, resolvePluginResource } from './package-meta.ts' +export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatalogEntry } from './official-bundles.ts' export { realModuleFile } from './profile-resolution/legacy-links.ts' export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts' export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts' diff --git a/packages/boot/app-boot/src/official-bundle-packages.ts b/packages/boot/app-boot/src/official-bundle-packages.ts new file mode 100644 index 0000000000..b77f9fcdd9 --- /dev/null +++ b/packages/boot/app-boot/src/official-bundle-packages.ts @@ -0,0 +1,7 @@ +/** Package admission shared by offline catalog generation and release ordering. */ + +/** Public DSH release members available through the ordinary on-demand bundle installer. */ +export const ON_DEMAND_BUNDLES: readonly string[] = [ + '@deepseek-ai/dsh-subagent-claude-code', + '@deepseek-ai/dsh-subagent-codex', +] diff --git a/packages/boot/app-boot/src/official-bundles.generated.ts b/packages/boot/app-boot/src/official-bundles.generated.ts new file mode 100644 index 0000000000..1fffe43c5e --- /dev/null +++ b/packages/boot/app-boot/src/official-bundles.generated.ts @@ -0,0 +1,23 @@ +/** Generated offline Official bundle metadata. Run pnpm gen-official-bundle-catalog; do not edit. */ + +import type { OfficialBundleCatalogEntry } from './official-bundles.ts' + +/** Official on-demand entries, independent of installed packages and registry availability. */ +export const OFFICIAL_ON_DEMAND_CATALOG: readonly OfficialBundleCatalogEntry[] = [ + { + packageName: '@deepseek-ai/dsh-subagent-claude-code', + meta: { + title: { 'en': 'Claude Code subagent', 'zh': 'Claude Code \u5B50\u667A\u80FD\u4F53' }, + description: { 'en': 'Delegate one task to Claude Code using its native authentication and settings.', 'zh': '\u4F7F\u7528 Claude Code \u539F\u751F\u8BA4\u8BC1\u548C\u8BBE\u7F6E\u59D4\u6D3E\u5355\u4E2A\u4EFB\u52A1\u3002' }, + icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K', + }, + }, + { + packageName: '@deepseek-ai/dsh-subagent-codex', + meta: { + title: { 'en': 'Codex subagent', 'zh': 'Codex \u5B50\u667A\u80FD\u4F53' }, + description: { 'en': 'Delegate one task to Codex using its native authentication and settings.', 'zh': '\u4F7F\u7528 Codex \u539F\u751F\u8BA4\u8BC1\u548C\u8BBE\u7F6E\u59D4\u6D3E\u5355\u4E2A\u4EFB\u52A1\u3002' }, + icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K', + }, + }, +] diff --git a/packages/boot/app-boot/src/official-bundles.ts b/packages/boot/app-boot/src/official-bundles.ts new file mode 100644 index 0000000000..fdcb8fda52 --- /dev/null +++ b/packages/boot/app-boot/src/official-bundles.ts @@ -0,0 +1,15 @@ +/** Official bundles offered without installing their provider runtimes in the DSH distribution. */ + +import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' + +export { ON_DEMAND_BUNDLES } from './official-bundle-packages.ts' + +/** Offline discovery metadata; installation state and the requested version belong to the running host. */ +export interface OfficialBundleCatalogEntry { + /** Exact npm package name accepted by the existing bundle installer. */ + readonly packageName: string + /** Package-owned localized text and a self-contained icon, embedded without provider code. */ + readonly meta: Required> +} + +export { OFFICIAL_ON_DEMAND_CATALOG } from './official-bundles.generated.ts' diff --git a/packages/boot/app-boot/tests/official-bundles.spec.ts b/packages/boot/app-boot/tests/official-bundles.spec.ts new file mode 100644 index 0000000000..37ab1f4a70 --- /dev/null +++ b/packages/boot/app-boot/tests/official-bundles.spec.ts @@ -0,0 +1,20 @@ +/** The offline catalog does not depend on installed provider packages or registry requests. */ + +import { describe, expect, it } from 'vitest' +import { OFFICIAL_ON_DEMAND_CATALOG, ON_DEMAND_BUNDLES } from '../src/official-bundles.ts' + +describe('Official on-demand catalog', () => { + it('offers exactly the two native subagent integrations with offline localized metadata', () => { + expect(ON_DEMAND_BUNDLES).toEqual(['@deepseek-ai/dsh-subagent-claude-code', '@deepseek-ai/dsh-subagent-codex']) + expect(OFFICIAL_ON_DEMAND_CATALOG.map(entry => entry.packageName)).toEqual(ON_DEMAND_BUNDLES) + for (const entry of OFFICIAL_ON_DEMAND_CATALOG) { + expect(entry.meta.icon).toMatch(/^data:image\/svg\+xml;base64,/) + for (const text of [entry.meta.title, entry.meta.description]) { + if (typeof text === 'string') throw new Error('Official metadata requires both shipped languages') + expect(text.en).toMatch(/\S/) + expect(text.zh).toMatch(/\S/) + } + expect(entry).not.toHaveProperty('version') + } + }) +}) diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index e025fcd739..3202076c88 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: 4b72417803fc5b3e - zh: 8f55ccd657483255 + en: 67f47e047834270d + zh: 9d9e1bdb92939532 /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 45d5b71fb7..18218e7ca5 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -45,8 +45,12 @@ Scoped bundle rows carry both their declared `rowId` and outer `preset` target. `listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` keeps `official` catalog membership independent of package installation. `availability` identifies a readable installation-provided or profile package, or `missing`; `installed` only records whether the profile declares the dependency. An absent on-demand entry carries embedded localized metadata and a host-derived `installTarget` containing the exact running DSH version and install spec, without package resolution or registry access. Profile-declared packages with unreadable files retain their declaration and expose the normal error. + `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. +Official on-demand installs and explicit updates call the same `installBundle` operation as other bundles, with `saveExact: true` to save an exact dependency version. Updates pass the current `enabled` selection; replacing an existing dependency reports `restart-required`. Turning Off only deselects the layer, and Remove uses the existing removal checks. Installation failure restores the profile manifest and lockfile; activation failure after installation can retain the saved dependency and selection. The existing peer-compatibility check still applies, without an additional Official-only version admission rule. + `installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 68be1fc889..81c5bdb818 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -45,8 +45,12 @@ kind: "package-reference" `listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 的 `official` 目录身份与包的安装状态独立。`availability` 表示可读取的安装随附包、profile 包,或 `missing`;`installed` 仅表示 profile 是否声明该依赖。尚未安装的按需条目携带内嵌的本地化元信息,以及 Host 根据当前 DSH 精确版本生成、包含版本与安装 spec 的 `installTarget`,无需解析包或访问注册表。profile 已声明但文件不可读的包保留依赖声明,并展示原有错误。 + `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` 会结束查询。 +官方按需安装与显式更新调用和其他组合包相同的 `installBundle` 操作,传入 `saveExact: true` 保存精确依赖版本。更新传入当前 `enabled` 选择状态;替换已有依赖会报告 `restart-required`。关闭仅取消选择配置层,移除使用原有移除检查。安装失败恢复 profile 清单和锁文件;安装成功后激活失败可能保留已保存的依赖和选择。原有 peer 兼容性检查继续生效,不增加仅针对官方条目的版本准入规则。 + `installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index b2626cc2ca..96cd5dc1d9 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -17,6 +17,7 @@ import { reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions, setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, profilePatchPreset, type ProfilePatch, + OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' @@ -273,7 +274,7 @@ export class PluginManager extends TypertRemoteService { }) } - /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + /** Read installed, installation-provided, and offline Official catalog bundles, plus selected non-bundle names. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. * @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional * display metadata, activation selections, whether the installation offers the bundle, and removal availability. @@ -288,42 +289,56 @@ export class PluginManager extends TypertRemoteService { const recorded = manifest.dependencies ?? {} const dependencies = Object.keys(recorded) const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest - const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])] + const catalog = new Map(OFFICIAL_ON_DEMAND_CATALOG.map(entry => [entry.packageName, entry])) + const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {}), ...catalog.keys()])] const bundles: BundleInfo[] = [] + const runtimeVersion = getDshRuntimeVersion() for (const name of names) { const installed = dependencies.includes(name) const optional = OPTIONAL_BUNDLES.includes(name) const enabled = selected.includes(name) const shipped = Object.hasOwn(installation.dependencies ?? {}, name) - // Bundle resolution reads the installation first, so a profile dependency the installation manifest also names, - // like one it forbids removing, is not the loaded copy. + const offered = catalog.get(name) + const official = optional || offered !== undefined + const target = offered === undefined ? {} : { installTarget: { spec: `${name}@${runtimeVersion}`, version: runtimeVersion } } + const catalogMeta = offered === undefined ? {} : { meta: offered.meta } + if (offered !== undefined && !installed && !shipped) { + bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional, + removable: enabled, rows: [], overrides: [], ...enabled ? { error: { code: 'not-bundle' } } : {} }) + continue + } + // Installation-owned copies remain protected even if a profile also declares their names. const owned = installed && !shipped - // A selection neither the profile nor the installation holds, such as a retired bundle, is removed by deselecting it. const removable = owned || (enabled && !installed && !shipped) const sourceOf = (packageName?: string): { source?: string } => owned ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir, packageName) } : {} const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined + let availability: BundleInfo['availability'] = 'missing' + let version: string | undefined try { const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) + availability = shipped ? 'installation' : 'profile' if (info === undefined) { - if (enabled) bundles.push({ name, ...sourceOf(), enabled, installed, optional, - removable: removable && readOnlyReason === undefined, + if (enabled || offered !== undefined) bundles.push({ name, official, availability, ...catalogMeta, ...target, + ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } + version = info.version 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) - bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), + const meta = offered?.meta ?? readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href) + bundles.push({ name, official, availability, ...target, ...(version === undefined ? {} : { version }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), ...meta === undefined ? {} : { meta }, ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info, inventory, definitions) }) } catch (error) { - if (enabled || installed) { - bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined, + if (enabled || installed || optional) { + bundles.push({ name, official, availability, ...catalogMeta, ...target, ...(version === undefined ? {} : { version }), + ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] }) } } @@ -472,7 +487,7 @@ export class PluginManager extends TypertRemoteService { * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, - * the pending build scripts to allow for this profile before pnpm runs, and the registry asked first. + * the pending build scripts to allow, whether to save an exact dependency, and the registry asked first. * @returns Package-manager diagnostics, the registries asked, and the observed activation outcome. */ @Remote @@ -521,7 +536,7 @@ export class PluginManager extends TypertRemoteService { if (stopped()) throw new InstallCancelledError() result.registries.push(registry) announce('installing', { registry, index: index + 1, total: plan.length }) - run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId) + run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId) result.packageResult = run if (stopped()) throw new InstallCancelledError() // A compatibility refusal is the package's own answer, so no other registry is asked. diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 2dd4dba957..d690003486 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -52,6 +52,12 @@ export interface BundleRowInfo { /** One installed or installation-provided bundle. */ export interface BundleInfo { name: string + /** Listed in the installation's Official catalog, independent of installation and selection. */ + official: boolean + /** Where a readable package is available; a missing package can still have a profile dependency declaration. */ + availability: 'installation' | 'profile' | 'missing' + /** Exact host-version target offered for an on-demand catalog entry. */ + installTarget?: { spec: string; version: string } version?: string /** Local display text with available translations or literal fallbacks, or a metadata diagnostic. */ meta?: PluginLocalizedMeta @@ -59,7 +65,7 @@ export interface BundleInfo { description?: string /** Selected in the profile manifest; a load error means its layer was skipped. */ enabled: boolean - /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ + /** Whether the profile declares this dependency; availability separately reports whether its files can be read. */ installed: boolean /** * Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local @@ -154,6 +160,8 @@ export type PluginInstallRequestId = Branded<'PluginInstallRequestId'> /** Bundle installation defaults to activation; callers that offer cancellation supply their request id. */ export interface InstallBundleOptions { enabled?: boolean + /** Save the resolved dependency version without a range; applies to any bundle installation. */ + saveExact?: boolean requestId?: PluginInstallRequestId /** Explicitly allow these pending packages' scripts for this profile, then install; a name no longer pending refuses the call. */ approvedBuilds?: string[] diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 6e17528400..f1dcaf56c8 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -12,7 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest' import { boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest, - reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta, getDshRuntimeVersion, + reconcileProfilePatches, OPTIONAL_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, PluginPackages, readPluginMeta, getDshRuntimeVersion, type ProfileContext, type RuntimeResolution, } from '@deepseek-ai/dsh-app-boot' import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts' @@ -40,8 +40,8 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre const anchor = join(home, 'package.json') writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n') initProfile(dir, ['core', 'extra']) - const bundle = (name: string, rows: unknown[]) => { - const path = join(dir, 'node_modules', name) + const bundle = (name: string, rows: unknown[], packageRoot = dir) => { + const path = join(packageRoot, 'node_modules', name) mkdirSync(path, { recursive: true }) writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify([{ insert: rows }])) @@ -294,14 +294,14 @@ it('lists bundle versions and current-profile plugin targets', async () => { const plugins = await manager.listPlugins() expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true }) expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required') - expect(await manager.listBundles()).toEqual([ + expect((await manager.listBundles()).filter(bundle => bundle.installTarget === undefined)).toEqual([ { - name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required', + name: 'core', version: '1.0.0', enabled: true, installed: false, official: false, availability: 'profile', optional: false, removable: false, readOnlyReason: 'management-required', meta: { title: 'core' }, rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [], }, { - name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, optional: false, removable: true, + name: 'extra', version: '1.0.0', source: 'extra@1.0.0', enabled: true, installed: true, official: false, availability: 'profile', optional: false, removable: true, meta: { title: 'extra' }, rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], }, @@ -323,7 +323,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({ - name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, optional: false, removable: true, + name: 'described', version: '2.0.0', description: 'Describes itself.', source: 'described@2.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, meta: { title: 'described', description: 'Describes itself.' }, rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], }) @@ -355,7 +355,8 @@ it('names where each installed bundle comes from as a spec pnpm installs', async writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) // The installation's own copy of a bundle is the one that loads, so the profile's dependency on it names nothing. writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { shadowed: '1.0.0' } })) - expect(Object.fromEntries((await manager.listBundles()).map(row => [row.name, row.source]))).toEqual({ + expect(Object.fromEntries((await manager.listBundles()) + .filter(row => row.installTarget === undefined).map(row => [row.name, row.source]))).toEqual({ core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', jsr: 'jsr@jsr:@acme/jsr@^1', github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://git.example.com/repo.git', @@ -446,7 +447,7 @@ it.each([ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) expect((await manager.listBundles()).find(row => row.name === 'unnamed')).toEqual({ - name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, optional: false, removable: true, + name: 'unnamed', version: '1.0.0', source: 'unnamed@1.0.0', enabled: false, installed: true, official: false, availability: 'profile', optional: false, removable: true, rows: [], overrides: [], ...expected, }) }) @@ -708,10 +709,10 @@ it('reports a selected plain dependency as a problem, omits an unselected one, a writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}') expect((await manager.listBundles())[0]?.version).toBeUndefined() writeFileSync(join(dir, 'package.json'), '{}') - expect(await manager.listBundles()).toEqual([]) + expect((await manager.listBundles()).filter(bundle => bundle.installTarget === undefined)).toEqual([]) expect(await manager.setBundleEnabled('unknown', false)).toMatchObject({ application: 'failed' }) writeFileSync(profile.installAnchor, '{"dependencies":{"missing-builtin":"1"}}') - expect(await manager.listBundles()).toEqual([]) + expect((await manager.listBundles()).filter(bundle => bundle.installTarget === undefined)).toEqual([]) }) it('refuses management bundle disablement and permits repeated bundle selections', async () => { @@ -1084,7 +1085,7 @@ it.each(OPTIONAL_BUNDLES)('offers %s switched off and never removable', async (o expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({ name: offered, version: '3.0.0', description: 'Package one-liner.', meta: { title: offered, description: 'Package one-liner.' }, - enabled: false, installed: false, optional: true, removable: false, + enabled: false, installed: false, official: true, availability: 'installation', optional: true, removable: false, rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [], }) expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' }) @@ -1099,7 +1100,7 @@ it('removes a selected bundle no dependency holds by deselecting it without pnpm ...manifest, dsh: { profile: { bundles: [...manifest.dsh?.profile?.bundles ?? [], 'retired'] } }, })) expect((await manager.listBundles()).find(row => row.name === 'retired')).toMatchObject({ - enabled: true, installed: false, optional: false, removable: true, error: { code: 'operation-error' }, + enabled: true, installed: false, official: false, availability: 'missing', optional: false, removable: true, error: { code: 'operation-error' }, }) const pnpm = vi.spyOn(operations, 'runProfilePnpm') onTestFinished(() => { pnpm.mockRestore() }) @@ -1451,3 +1452,140 @@ it.each([false, true])('rechecks installed bundle peers before accepting a disab } expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra']) }) + + +it('lists absent Official entries offline without resolving their packages or consulting registries', async () => { + const { manager } = await fixture() + const resolve = vi.spyOn(operations, 'bundleManifest') + const registry = vi.spyOn(operations, 'viewProfilePackage') + onTestFinished(() => { resolve.mockRestore(); registry.mockRestore() }) + const bundles = await manager.listBundles() + for (const entry of OFFICIAL_ON_DEMAND_CATALOG) { + expect(bundles.filter(bundle => bundle.name === entry.packageName)).toEqual([{ + name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, + removable: false, rows: [], overrides: [], meta: entry.meta, + installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, + }]) + expect(resolve.mock.calls.some(([name]) => name === entry.packageName)).toBe(false) + } + expect(registry).not.toHaveBeenCalled() +}) + +it('distinguishes an unreadable profile dependency from an absent catalog selection', async () => { + const { manager, dir, profile } = await fixture() + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + const broken = join(profile.home, 'node_modules', name) + mkdirSync(broken, { recursive: true }) + writeFileSync(join(broken, 'package.json'), '{') + expect((await manager.listBundles()).find(bundle => bundle.name === name)).toMatchObject({ + official: true, installed: true, availability: 'missing', enabled: false, removable: true, + source: `${name}@1.0.0`, error: { code: 'operation-error' }, + }) +}) + +it('uses the generic exact installer and keeps an Official entry through on, off, and removal', async () => { + const { manager, dir, bundle, profile } = await fixture() + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const version = getDshRuntimeVersion() + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_dir, args) => { + const manifest = readProfileManifest('test', dir) + if (args[0] === 'add') { + bundle(name, [{ id: 'official-probe', name: './plugin.mjs', config: { service: 'officialProbe' } }], profile.home) + const packagePath = join(profile.home, 'node_modules', name, 'package.json') + const packaged = JSON.parse(readFileSync(packagePath, 'utf8')) as Record + writeFileSync(packagePath, JSON.stringify({ ...packaged, version })) + manifest.dependencies = { ...manifest.dependencies, [name]: version } + } else { + delete manifest.dependencies?.[name] + rmSync(join(profile.home, 'node_modules', name), { recursive: true, force: true }) + } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } + }) + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle(`${name}@${version}`, { saveExact: true })).toMatchObject({ application: 'applied', enabled: true }) + expect(pnpm.mock.calls[0]?.[1]).toContain('--save-exact') + expect((await manager.listBundles()).find(item => item.name === name)).toMatchObject({ official: true, availability: 'profile', enabled: true, version }) + expect(await manager.setBundleEnabled(name, false)).toMatchObject({ application: 'applied' }) + expect(readProfileManifest('test', dir).dependencies?.[name]).toBe(version) + expect(await manager.removeBundle(name)).toMatchObject({ application: 'applied' }) + expect((await manager.listBundles()).filter(item => item.name === name)).toHaveLength(1) + expect((await manager.listBundles()).find(item => item.name === name)).toMatchObject({ official: true, availability: 'missing', enabled: false, installed: false }) +}) + +it.each([true, false])('updates an Official dependency while preserving selection=%s and requiring restart', async (enabled) => { + const { manager, dir, bundle, profile } = await fixture() + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + bundle(name, [], profile.home) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + if (enabled) manifest.dsh!.profile!.bundles!.push(name) + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + const listed = (await manager.listBundles()).find(item => item.name === name) + expect(listed).toMatchObject({ version: '1.0.0', official: true, availability: 'profile', + installTarget: { spec: `${name}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() } }) + expect(listed?.error).toBeUndefined() + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + const current = readProfileManifest('test', dir) + current.dependencies = { ...current.dependencies, [name]: getDshRuntimeVersion() } + writeFileSync(join(dir, 'package.json'), JSON.stringify(current)) + return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } + }) + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle(`${name}@${getDshRuntimeVersion()}`, { enabled, saveExact: true })) + .toMatchObject({ application: 'restart-required', enabled }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles?.includes(name)).toBe(enabled) +}) + +it('restores dependency and selection after an exact version is unavailable', async () => { + const { manager, dir, bundle, profile } = await fixture() + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + bundle(name, [], profile.home) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' } + manifest.dsh!.profile!.bundles!.push(name) + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + writeFileSync(join(dir, 'package.json'), '{}') + return { exitCode: 1, output: 'No matching version', truncated: false, logPath: '/install.log', kind: 'no-matching-version' } + }) + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle(`${name}@${getDshRuntimeVersion()}`, { saveExact: true })).toMatchObject({ application: 'failed', stage: 'install' }) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + +it('retains a successful installation and saved selection when Official activation fails', async () => { + const { manager, dir, bundle, profile } = await fixture() + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + bundle(name, [{ id: 'official-probe', name: './plugin.mjs', config: { fail: true } }], profile.home) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, [name]: getDshRuntimeVersion() } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: '', truncated: false, logPath: '/install.log' } + }) + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle(`${name}@${getDshRuntimeVersion()}`, { saveExact: true })).toMatchObject({ application: 'failed', stage: 'enable' }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain(name) + expect(readProfileManifest('test', dir).dependencies?.[name]).toBe(getDshRuntimeVersion()) +}) + +it('reports and removes a saved Official selection whose dependency is absent', async () => { + const { manager, dir } = await fixture() + const name = OFFICIAL_ON_DEMAND_CATALOG[0]!.packageName + const manifest = readProfileManifest('test', dir) + manifest.dsh!.profile!.bundles!.push(name) + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + expect((await manager.listBundles()).find(bundle => bundle.name === name)).toMatchObject({ + official: true, installed: false, availability: 'missing', enabled: true, removable: true, error: { code: 'not-bundle' }, + }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.removeBundle(name)).toMatchObject({ application: 'applied' }) + expect(pnpm).not.toHaveBeenCalled() + expect((await manager.listBundles()).find(bundle => bundle.name === name)).toMatchObject({ availability: 'missing', enabled: false }) +}) diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml index 8ac2b0604e..277176f1a0 100644 --- a/packages/bundle/web-app/presets/cordis.patch.yml +++ b/packages/bundle/web-app/presets/cordis.patch.yml @@ -112,20 +112,6 @@ - schedule_delete - schedule_list - schedule_update - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - maxDepth: provider-managed - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - maxDepth: provider-managed - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index 8af9c4a226..1aed4cb9e9 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -113,20 +113,6 @@ - schedule_delete - schedule_list - schedule_update - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - maxDepth: provider-managed - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - maxDepth: provider-managed - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' disabled: true diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml index 912e9844e2..f2a9e080fe 100644 --- a/packages/bundle/web-app/presets/standard.patch.yml +++ b/packages/bundle/web-app/presets/standard.patch.yml @@ -113,20 +113,6 @@ - schedule_delete - schedule_list - schedule_update - - id: tool-subagent-codex - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: codex - toolName: subagent_codex - maxDepth: provider-managed - - id: tool-subagent-claude-code - name: '@deepseek-ai/dsh-tool-subagent' - disabled: true - config: - provider: claude-code - toolName: subagent_claude_code - maxDepth: provider-managed - id: workflow-ptc name: '@deepseek-ai/dsh-workflow-ptc' config: diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b6760df9ed..27bdf61776 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: c41c530198c0d610 - zh: 584a6d658ff9f022 + en: f72a9368846d5320 + zh: de826dcd7dce5c1d /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 70b3d1dfc27406ca - zh: 9a65898773856d71 + en: 36acfd5776cfae9b + zh: be23d0a20c051dc5 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: 2d29970dcd0b2836 zh: 73f6b519546d2b6b @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: 2064958b0754647e /deepseek-ai-dsh-client-ui-plugin-manager/known-limitations-and-deferred-work: - en: e9e56a6f084e7ebb - zh: bebb7086ed0237bf + en: 7148d0121ea9d015 + zh: f62d6a4c212fb871 /deepseek-ai-dsh-client-ui-plugin-manager/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 65aa310b36..b72a47f5c6 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,10 +27,12 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -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. +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 installation-carried optional bundles and on-demand catalog entries, followed by official plugins that registered a configuration page. Catalog entries remain in Official after installation or removal; **Installed** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages carry the **Experimental** marker. 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 and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. +On-demand Official cards are visible offline before installation. Turning one On opens the existing install progress dialog, installs the exact version offered by the running DSH host, and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is a separate action. When an installed catalog package differs from the host target version, its detail page offers **Update** and displays both versions. Update preserves selection and reports any required restart. A package with missing or damaged files remains distinguishable from an installation-carried package. + 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. The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner. @@ -50,7 +52,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the package-name example; **Use example** drops it into the field. Typing or pasting the Git or local-path template verbatim still raises the reminder asking for the actual value. A trust box above **Install** stays visible including when the guide is collapsed, and adds that installed plugins do not update automatically: upgrading means uninstalling and installing the new version, which later releases will keep improving. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install shows the version pnpm installed; when a run that asked one registry installed another version than the one read, it names both versions, attributes the difference to pnpm's supply-chain security policy (`minimumReleaseAge`), states the risk of a newly published version, and gives the exact `name@version` to install after uninstalling. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the package-name example; **Use example** drops it into the field. Typing or pasting the Git or local-path template verbatim still raises the reminder asking for the actual value. A trust box above **Install** stays visible including when the guide is collapsed, and explains that on-demand Official entries offer explicit Update, while other plugins are upgraded by uninstalling and reinstalling. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install shows the version pnpm installed; when a run that asked one registry installed another version than the one read, it names both versions, attributes the difference to pnpm's supply-chain security policy (`minimumReleaseAge`), states the risk of a newly published version, and gives the exact `name@version` to install after uninstalling. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. @@ -160,7 +162,7 @@ These limits define the reach of the management view; they are current package c - **Only bundles are managed** — a dependency without a bundle patch is refused before it installs; one the profile already holds is left off the page unless the profile selects it, and loading plain plugin modules stays a file operation. - **Rows show a phase, not a reason** — a failed row reads as failed without the Host's error text; the Host log has it. - **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish. -- **No version picker** — the spec is typed as pnpm accepts it; the page neither lists registry versions nor offers upgrades. Its refusals and the incompatibility reason on an installed plugin tell the person to upgrade a profile-installed plugin by uninstalling and installing it again, and that a plugin shipped with DSH upgrades with DSH. +- **No version picker** — the spec is typed as pnpm accepts it; the page does not list registry versions. On-demand Official entries offer the exact version for the running DSH release. Other profile-installed plugins use uninstall and reinstall to change versions; installation-carried bundles upgrade with DSH. - **The registry choice is this browser's** — it lives in `localStorage`, so another browser applies its own initial recommendation; the `dsh plugin` command and the agent tool use the Host's configured registry. - **Every registry read runs pnpm** — opening the dialog, the check, and the install each ask pnpm what its own configuration names; a machine without pnpm reads it as unknown and is offered no fallback. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 7b6d78ff1a..dcfd02a5fa 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,10 +27,12 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于实验性功能的带**实验性**标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附的可选组合包与按需目录条目,其后是注册了配置页的官方插件。目录条目在安装或移除后仍留在官方分组;**已安装**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包带有**实验性**标签。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 +官方按需卡片在安装前即可离线显示。开启会打开已有的安装进度对话框,安装当前 DSH Host 提供的精确版本并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。已安装目录包的版本与 Host 目标版本不同时,详情页提供**更新**并显示两个版本。更新保留选择状态,并报告是否需要重启。文件缺失或损坏的包与安装随附的包保持明确区分。 + 首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 **刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。 @@ -50,7 +52,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出包名示例;**填入示例**把它填进输入框。输入框中若原样保留 Git 地址或本地路径的模板文本,仍会显示要求替换为实际值的提醒。**安装**按钮上方的提示框在引导收起后也可见,并说明插件安装后暂不支持自动更新:升级需先卸载再安装新版,后续版本会持续改善升级体验。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成的画面显示 pnpm 实际安装的版本;只问过一个注册表的运行装入的版本与检查读到的不同时,会写明两个版本,说明差异来自 pnpm 供应链安全策略(`minimumReleaseAge`),提示新发布版本的风险,并给出卸载后应安装的精确 `name@version`。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出包名示例;**填入示例**把它填进输入框。输入框中若原样保留 Git 地址或本地路径的模板文本,仍会显示要求替换为实际值的提醒。**安装**按钮上方的提示框在引导收起后也可见,并说明官方按需插件提供显式更新;其他插件通过卸载后重新安装升级。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成的画面显示 pnpm 实际安装的版本;只问过一个注册表的运行装入的版本与检查读到的不同时,会写明两个版本,说明差异来自 pnpm 供应链安全策略(`minimumReleaseAge`),提示新发布版本的风险,并给出卸载后应安装的精确 `name@version`。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 @@ -160,7 +162,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` - **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝;profile 里已有的这类依赖不上页面,除非 profile 选中了它;加载普通插件模块仍是文件操作。 - **行只显示阶段,不显示原因**——失败的行只显示为失败,没有 Host 的错误文本;Host 日志里有。 - **一次只能安装一个**——对话框一次运行一个 pnpm 命令;第二个 spec 要等前一个完成。 -- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面不列出注册表版本,也不提供升级。拒绝提示和已安装插件的不兼容原因会告诉用户:profile 安装的插件通过卸载后重新安装来升级,随 DSH 提供的插件随 DSH 升级。 +- **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面不列出注册表版本。官方按需条目提供与当前 DSH 发布版本对应的精确版本。其他 profile 插件通过卸载后重新安装更改版本;安装随附的组合包随 DSH 升级。 - **安装源选择只属于本浏览器**——它存在 `localStorage` 里,所以另一个浏览器会独立计算初始推荐;`dsh plugin` 命令和 agent 工具使用 Host 配置的注册表。 - **每次读注册表都要运行 pnpm**——打开对话框、检查、安装各问一次 pnpm 自身配置指向哪里;没有 pnpm 的机器读作未知,不提供备选。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 42be5eac9a..2f7912e890 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -317,14 +317,14 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { * section. */ function SourceSection({ pkg, t }: { readonly pkg: PackageView; readonly t: Translate }): ReactNode { - if (pkg.source === undefined && !pkg.installed && !pkg.optional) return null + if (pkg.source === undefined && !pkg.installed && pkg.availability === 'installation' && !pkg.optional) return null return (

{t('sourceTitle')}

{t('sourceSpec')}
-
{pkg.source === undefined ? t('sourceBuiltIn') : {pkg.source}}
+
{pkg.availability === 'missing' ? t(pkg.installed ? 'statusMissingFiles' : 'sourceNotInstalled') : pkg.source === undefined ? t('sourceBuiltIn') : {pkg.source}}
{pkg.version === undefined ? null @@ -334,6 +334,12 @@ function SourceSection({ pkg, t }: { readonly pkg: PackageView; readonly t: Tran
{pkg.version}
)} + {pkg.installTarget === undefined ? null : ( +
+
{t('sourceTargetVersion')}
+
{pkg.installTarget.version}
+
+ )}
) @@ -354,7 +360,7 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: { @@ -448,6 +454,10 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { ) } +function updateAvailable(pkg: PackageView): boolean { + return pkg.installed && pkg.availability !== 'installation' && pkg.installTarget !== undefined && pkg.version !== pkg.installTarget.version +} + /** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnabled }: { readonly pkg: PackageView @@ -476,6 +486,8 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab <> {beta ? {t('statusBeta')} : null} {status === 'problem' ? {t('statusProblem')} : null} + {pkg.availability === 'missing' ? {t(pkg.installed ? 'statusMissingFiles' : 'statusNotInstalled')} : null} + {updateAvailable(pkg) ? {t('statusUpdateAvailable')} : null} )} description={description} @@ -515,6 +527,8 @@ function packageRef(pkg: PackageView): PluginPackageRef { name: pkg.name, ...pkg.version === undefined ? {} : { version: pkg.version }, installed: pkg.installed, + availability: pkg.availability, + official: pkg.official, enabled: pkg.enabled, rows: pkg.rows.map(rowRef), } @@ -613,7 +627,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot, form }: { */ function PackageDetail({ pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, - onBack, onSetEnabled, onUninstall, onSetRowEnabled, + onBack, onSetEnabled, onUninstall, onUpdate, onSetRowEnabled, }: { readonly pkg: PackageView readonly t: Translate @@ -628,6 +642,7 @@ function PackageDetail({ readonly onBack: () => void readonly onSetEnabled: (enabled: boolean) => void readonly onUninstall: () => void + readonly onUpdate: () => void readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void }): ReactNode { const { title, description, beta } = packageText(pkg, resolveText) @@ -643,6 +658,12 @@ function PackageDetail({ actions={(
{renderSlot('plugins.detail.actions', { subject })} + {updateAvailable(pkg) ? ( + + ) : null} {pkg.installed || pkg.removable ? (

{pkg.name}

@@ -684,13 +707,13 @@ function PackageDetail({ ) : null} - busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} configure={configure} - /> + />)} {renderSlot('plugins.detail.section', { subject })} @@ -1301,7 +1324,7 @@ function InstallDialog({ : null} {phase !== 'done' ? null - : install.installed !== null + : install.installed !== null && install.subject?.selection === undefined ? : } @@ -1366,9 +1389,9 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { // selected name the Host cannot read; the installation's other bundles are inspected in the Settings // Plugins section's Plugin list tab. const listed = state.packages.filter(pkg => !BUILTIN_PROFILE_BUNDLES.has(pkg.name) - && (pkg.installed || pkg.optional || pkg.error !== undefined)) - const mine = listed.filter(pkg => pkg.installed || !pkg.optional) - const official = listed.filter(pkg => pkg.optional && !pkg.installed) + && (pkg.installed || pkg.official || pkg.error !== undefined)) + const mine = listed.filter(pkg => !pkg.official) + const official = listed.filter(pkg => pkg.official) const loaded = state.status === 'ready' || state.status === 'error' const refreshing = state.refreshStatus === 'refreshing' const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined @@ -1384,13 +1407,15 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { has: row => row.preset === undefined && ledger.rows.has(rowConfigKey(pkg.name, row.rowId)), open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: packageRowKey(row) }) }, }) + const packageBusy = (pkg: PackageView): boolean => state.busy.includes(pkg.name) + || (state.install.subject?.name === pkg.name && (isInstallPending(state.install.phase) || state.install.phase === 'checking')) const packageCard = (pkg: PackageView): ReactNode => ( { setActivation(null); setView({ kind: 'package', name: pkg.name }) }} onSetEnabled={(enabled) => { setActivation(enabled ? pkg.name : null); props.setEnabled(pkg.name, enabled) }} @@ -1495,7 +1520,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { pkg={openPkg} t={t} resolveText={resolveText} - busy={state.busy.includes(openPkg.name)} + busy={packageBusy(openPkg)} rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} configured={ledger.bundles.has(openPkg.name)} configure={configure(openPkg)} @@ -1503,6 +1528,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { onBack={() => { setView({ kind: 'list' }) }} onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }} onUninstall={() => { props.uninstall(openPkg.name) }} + onUpdate={() => { props.update(openPkg.name) }} onSetRowEnabled={setRowEnabled} /> ) diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index be69b82a74..e53c11b193 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -76,7 +76,7 @@ export const zh = { installGuideFill: '填入示例', installGuideFillAria: '填入示例 {example}', installGuideSafety: '请确认插件来源可信。插件在本机以你的权限运行,来源不明的插件可能损坏 DeepSeek Harness,或读取和泄露你的数据。', - installUpgradeNotice: '插件安装后,暂不支持自动更新。若需升级,请先卸载再安装新版,后续版本会持续改善升级体验。', + installUpgradeNotice: '按需安装的官方插件会在版本与 DSH 不同时提供“更新”。其他插件请先卸载再安装新版', registryToggle: '安装源', registryLegend: '从哪个 npm 源下载插件', registryDefault: '默认安装源', @@ -101,6 +101,13 @@ export const zh = { installProblemUnknown: '无法获取插件信息:{reason}', installingTitle: '插件安装中…', installedTitle: '已安装', + statusNotInstalled: '未安装', + statusMissingFiles: '包不可用', + statusUpdateAvailable: '可更新', + sourceNotInstalled: '未安装', + sourceTargetVersion: '当前 DSH 对应版本', + update: '更新', + updateLabel: '更新 {name}', installFailedTitle: '插件安装失败', installGithubFailedTitle: '无法访问 GitHub', installGithubTimeoutTitle: '连接 GitHub 超时', @@ -282,7 +289,7 @@ export const en = { installGuideFill: 'Use example', installGuideFillAria: 'Use the example {example}', installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.', - installUpgradeNotice: 'Installed plugins do not update automatically yet. To upgrade a plugin, uninstall it and install the new version. Later releases will keep improving the upgrade experience.', + installUpgradeNotice: 'On-demand Official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', registryToggle: 'Registry', registryLegend: 'The npm registry the plugin is downloaded from', registryDefault: 'Default registry', @@ -307,6 +314,13 @@ export const en = { installProblemUnknown: 'The plugin could not be looked up: {reason}', installingTitle: 'Installing the plugin…', installedTitle: 'Installed', + statusNotInstalled: 'Not installed', + statusMissingFiles: 'Package unavailable', + statusUpdateAvailable: 'Update available', + sourceNotInstalled: 'Not installed', + sourceTargetVersion: 'Version for this DSH release', + update: 'Update', + updateLabel: 'Update {name}', installFailedTitle: 'The plugin could not be installed', installGithubFailedTitle: 'Cannot access GitHub', installGithubTimeoutTitle: 'GitHub connection timed out', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 71269cfad8..051553c5db 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -83,11 +83,17 @@ export interface PackageRow { /** One bundle as the page shows it: the Host's bundle joined with the entries its rows run as. */ export interface PackageView { readonly name: string + /** Official catalog identity is independent of installation and selection. */ + readonly official: boolean + /** Readable package location, separately from the profile dependency declaration. */ + readonly availability: BundleInfo['availability'] + /** Host-derived exact version offered for installation or an explicit update. */ + readonly installTarget?: NonNullable readonly version?: string readonly description?: string /** Local package display text and metadata diagnostics supplied by the Host. */ readonly meta?: PluginLocalizedMeta - /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ + /** Whether the profile declares this dependency; availability separately describes its readable files. */ readonly installed: boolean /** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */ readonly source?: string @@ -105,7 +111,13 @@ export interface PackageView { } /** The typed spec as the Host read it, on the installing, installed, and failed screens. */ -export type InstallSubject = Extract & { readonly spec: string } +export type InstallSubject = Extract & { + readonly spec: string + /** Present for a catalog install or update: desired selection after installation. */ + readonly selection?: boolean + /** Save an exact dependency version through the generic installer. */ + readonly saveExact?: boolean +} /** The registry the person picked for an install: one the Host offers, or a typed URL. */ export type RegistryChoice = @@ -210,7 +222,7 @@ export interface InstallState { readonly runs: readonly InstallRun[] /** Whether the run's command and output are unfolded, including after reopening. */ readonly detailsOpen: boolean - /** The bundle the finished run added, left off until enabled from the installed screen. */ + /** The bundle the finished run added; ordinary installs await enablement, catalog operations carry their selection. */ readonly installed: string | null /** The version the finished run installed, when its manifest declares one; null otherwise. */ readonly installedVersion: string | null @@ -346,6 +358,8 @@ export interface PluginManagerFace { clearHighlight: () => void /** Put a bundle into, or take it out of, the profile's layer list. */ setEnabled: (packageName: string, enabled: boolean) => void + /** Install the host's catalog version while retaining the current selection. */ + update: (packageName: string) => void /** Ask before removing a package from the profile. */ uninstall: (packageName: string) => void confirm: () => void @@ -441,6 +455,9 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): }) return { name: bundle.name, + official: bundle.official, + availability: bundle.availability, + ...bundle.installTarget === undefined ? {} : { installTarget: bundle.installTarget }, installed: bundle.installed, optional: bundle.optional, removable: bundle.removable, @@ -474,7 +491,9 @@ const IDLE_INSTALL: InstallState = { /** The dialog back at its spec: the run and its outcome forgotten, the spec and the registries kept. */ function specAgain(install: InstallState): InstallState { const { open, spec, registries, registry, mirrorRecovery } = install - return { ...IDLE_INSTALL, open, spec, registries, registry, ...mirrorRecovery === undefined ? {} : { mirrorRecovery } } + return { ...IDLE_INSTALL, open, spec, registries, registry, + ...install.subject?.selection === undefined ? {} : { subject: install.subject }, + ...mirrorRecovery === undefined ? {} : { mirrorRecovery } } } /** @@ -611,7 +630,8 @@ export class PluginManagerController { const install = this.getSnapshot().install // Typing while the Host checks or installs is not possible; a new spec after an outcome starts over. if (install.phase === 'checking' || isInstallPending(install.phase)) return - this.patchInstall(install.phase === 'idle' ? { spec: text, inputError: null } : { ...specAgain(install), spec: text }) + this.patchInstall(install.phase === 'idle' ? { spec: text, inputError: null, subject: null } + : { ...specAgain(install), spec: text, subject: null }) }, runInstall: () => { void this.runInstall() }, toggleRegistryOptions: () => { this.patchInstall({ registryOpen: !this.getSnapshot().install.registryOpen }) }, @@ -643,12 +663,21 @@ export class PluginManagerController { enableInstalled: () => { void this.enableInstalled() }, clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) }, setEnabled: (packageName, enabled) => { + const pkg = this.getSnapshot().packages.find(pkg => pkg.name === packageName) + if (enabled && pkg?.availability === 'missing' && pkg.installTarget !== undefined) { + this.openCatalogInstall(pkg, pkg.installTarget, true) + return + } void this.run(packageName, { packageName, action: enabled ? 'enable' : 'disable' }, async () => { const result = await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled) this.applied(result, packageName) if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(packageName, enabled) }) }, + update: (packageName) => { + const pkg = this.getSnapshot().packages.find(pkg => pkg.name === packageName) + if (pkg?.installTarget !== undefined && pkg.availability !== 'installation') this.openCatalogInstall(pkg, pkg.installTarget, pkg.enabled) + }, uninstall: (packageName) => { this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => { this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName }) @@ -847,6 +876,28 @@ export class PluginManagerController { return this.disposed || signal.aborted } + /** Reuse the install dialog with an exact catalog target and an explicit selection outcome. */ + private openCatalogInstall(pkg: PackageView, target: NonNullable, enabled: boolean): void { + if (this.disposed) return + const { phase } = this.getSnapshot().install + if (phase === 'checking' || isInstallPending(phase)) { + this.patchInstall({ open: true }) + return + } + this.abortInspect() + const registry = this.registryMemory.getSnapshot() ?? OFFICIAL_REGISTRY + const subject: InstallSubject = { + status: 'accepted', kind: 'registry', name: pkg.name, version: target.version, bundle: true, + spec: target.spec, registry: registry.kind === 'custom' ? registry.url : registry.registry, + selection: enabled, saveExact: true, + } + this.patch({ install: { ...IDLE_INSTALL, open: true, spec: subject.spec, registry, subject } }) + const read: RegistryRead = { choice: registry } + this.registryRead = read + read.done = this.readRegistries(read).finally(() => { delete read.done }) + void this.runInstall() + } + /** * Check the typed spec, then install it. The Host reads what the spec * names first; a refused spec returns to the field with the reason, an @@ -863,9 +914,10 @@ export class PluginManagerController { } // A name the list already shows is refused at once, before the Host is asked. const listed = state.packages.find(pkg => pkg.name === spec) - if (listed !== undefined) { + const catalogSubject = install.subject?.selection === undefined || install.subject.spec !== spec ? undefined : install.subject + if (listed !== undefined && listed.availability !== 'missing' && catalogSubject === undefined) { this.finishAnalytics('failed', 'already-installed') - this.patchInstall({ phase: 'idle', inputError: { problem: listed.installed ? 'already-installed' : 'shipped', reason: spec } }) + this.patchInstall({ phase: 'idle', inputError: { problem: listed.availability === 'installation' ? 'shipped' : 'already-installed', reason: spec } }) return } const choice = install.registry @@ -879,7 +931,7 @@ export class PluginManagerController { const controller = new AbortController() this.inspectAbort = controller this.patchInstall({ - phase: 'checking', inputError: null, subject: null, runs: [], detailsOpen: false, attempts: null, registryOpen: false, + phase: 'checking', inputError: null, subject: catalogSubject ?? null, runs: [], detailsOpen: false, attempts: null, registryOpen: false, installed: null, installedVersion: null, restartRequired: false, failure: null, approvedBuilds: [], }) const read = this.registryRead @@ -891,6 +943,11 @@ export class PluginManagerController { const selected = currentChoice.kind === 'custom' ? { ...currentChoice, url: currentChoice.url.trim() } : currentChoice const registry: Registry = selected.kind === 'custom' ? selected.url : selected.registry this.registryMemory.set(selected) + if (catalogSubject !== undefined) { + this.inspectAbort = undefined + await this.startInstall({ ...catalogSubject, registry }) + return + } const inspected = await this.ctx.remote.pluginManager.inspect(spec, { registry }, controller.signal) if (this.gone(controller.signal)) return this.inspectAbort = undefined @@ -923,7 +980,9 @@ export class PluginManagerController { // the wire, and every such event reads again; those reads must not cancel // the run's settlement. const result = await this.ctx.remote.pluginManager.installBundle(spec, { - enabled: false, requestId, registry, ...approvedBuilds === undefined ? {} : { approvedBuilds: [...approvedBuilds] }, + enabled: subject.selection ?? false, requestId, registry, + ...subject.saveExact === undefined ? {} : { saveExact: subject.saveExact }, + ...approvedBuilds === undefined ? {} : { approvedBuilds: [...approvedBuilds] }, }) if (this.disposed || this.request !== request) return if (!result.ok) { @@ -985,6 +1044,12 @@ export class PluginManagerController { ...asked, }) } + const completed = this.getSnapshot().install + if (completed.phase === 'done' && completed.subject?.selection !== undefined && result.bundle !== undefined) { + this.applied({ ok: true, value: result }, result.bundle) + this.patch({ highlight: result.bundle }) + if (completed.subject.selection) this.trackToggle(result.bundle, true) + } const phase = this.getSnapshot().install.phase if (phase === 'done' || phase === 'failed') this.notifyHiddenInstall(phase) void this.load() @@ -1161,7 +1226,7 @@ export class PluginManagerController { const pluginName = row ? bundle?.rows.find(item => item.entryId === name)?.moduleName : name if (bundle === undefined || pluginName === undefined) return this.ctx.get('productAnalytics')?.track('plugin_toggle', { - plugin_name: pluginName, plugin_type: row ? 'plugin' : 'bundle', is_enabled: enabled, is_builtin: !bundle.installed, + plugin_name: pluginName, plugin_type: row ? 'plugin' : 'bundle', is_enabled: enabled, is_builtin: bundle.availability === 'installation', }) } diff --git a/packages/client/ui-plugin-manager/src/client/slot-contract.ts b/packages/client/ui-plugin-manager/src/client/slot-contract.ts index fc0bad0e5e..f2dce8cc57 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -17,6 +17,7 @@ */ import type {} from '@deepseek-ai/dsh-client-ui-slots' +import type { BundleInfo } from '@deepseek-ai/dsh-api-remotes/client' import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' /** The view the page asks a configuration entry for. */ @@ -45,8 +46,12 @@ export interface PluginPackageRef { readonly name: string /** The installed version, when the Host reports one. */ readonly version?: string - /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ + /** Whether the profile declares the package dependency. */ readonly installed: boolean + /** Readable package location; missing entries can retain a dependency declaration. */ + readonly availability: BundleInfo['availability'] + /** Whether the installation offers this package in Official. */ + readonly official: boolean /** Whether the bundle is switched on. */ readonly enabled: boolean /** The rows the bundle declares. */ 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 9c088de3ea..d663389d0f 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -32,6 +32,8 @@ const t = translate(en) function pkg(overrides: Partial = {}): PackageView { return { name: 'dsh-better-sidebar', + official: overrides.optional === true, + availability: overrides.installed === false ? 'installation' : 'profile', version: '0.16.0', installed: true, optional: false, @@ -110,6 +112,7 @@ function renderTab( closeInstall: vi.fn(), editInstallSpec: vi.fn(), runInstall: vi.fn(), + update: vi.fn(), cancelInstall: vi.fn(), reconcileInstall: vi.fn(), toggleInstallDetails: vi.fn(), @@ -289,9 +292,9 @@ describe('PluginManagerPage', () => { it('says where a bundle comes from: the spec that installs it, or built in, with its version', () => { const b = renderTab({ packages: [ pkg({ source: 'github:someone/dsh-better-sidebar' }), - { name: 'dsh-official', installed: false, optional: true, removable: false, enabled: false, rows: [] }, - { name: 'dsh-shadowed', installed: true, optional: false, removable: false, enabled: true, rows: [] }, - { name: 'dsh-missing', installed: false, optional: false, removable: true, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, + { name: 'dsh-official', installed: false, official: true, availability: 'installation', optional: true, removable: false, enabled: false, rows: [] }, + { name: 'dsh-shadowed', installed: true, official: false, availability: 'profile', optional: false, removable: false, enabled: true, rows: [] }, + { name: 'dsh-missing', installed: false, official: false, availability: 'missing', optional: false, removable: true, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, ] }) const facts = (): string[] => [...document.querySelectorAll('[data-plugin-source] dt, [data-plugin-source] dd')].map(node => node.textContent) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-better-sidebar' }) }) @@ -303,7 +306,7 @@ describe('PluginManagerPage', () => { act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-shadowed' }) }) expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) }) - expect(document.querySelector('[data-plugin-source]')).toBeNull() + expect(facts()).toEqual([en.sourceSpec, en.sourceNotInstalled]) }) it('preserves the requested bundle through StrictMode effect replay and page remounts', () => { @@ -1007,7 +1010,7 @@ describe('PluginManagerPage', () => { // A contribution sees the bundle's facts, not the page's own state. expect(subjects.at(-1)).toEqual({ kind: 'bundle', - pkg: { name: 'dsh-better-sidebar', version: '0.16.0', installed: true, enabled: true, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true }] }, + pkg: { name: 'dsh-better-sidebar', version: '0.16.0', installed: true, official: false, availability: 'profile', enabled: true, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', enabled: true }] }, }) fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })) @@ -1028,10 +1031,10 @@ describe('PluginManagerPage', () => { }) it('leaves the version out of a bundle the Host reports none for', () => { - const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, optional: false, removable: true, enabled: true, rows: [] } + const unversioned: PackageView = { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', optional: false, removable: true, enabled: true, rows: [] } renderTab({ packages: [unversioned] }, {}, bodies) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) - expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, enabled: true, rows: [] } }) + expect(subjects.at(-1)).toEqual({ kind: 'bundle', pkg: { name: 'dsh-better-sidebar', installed: true, official: false, availability: 'profile', enabled: true, rows: [] } }) }) }) @@ -2037,3 +2040,70 @@ it('supplies the accepted entry values and atomic mutation action to a custom pl fireEvent.click(screen.getByText('Save custom')) expect(mutate).toHaveBeenCalledWith([{ op: 'set', path: ['count'], value: 3 }], 7) }) + + +describe('on-demand Official bundles', () => { + const name = '@deepseek-ai/dsh-subagent-codex' + const title = 'Codex subagent' + const catalog = (): PackageView => ({ name, meta: { title }, official: true, availability: 'missing', installed: false, + optional: false, removable: false, enabled: false, rows: [], + installTarget: { spec: `${name}@2.0.0`, version: '2.0.0' }, + }) + + it('keeps one card in Official before installation, after installation, and after removal', () => { + const entry = catalog() + const { actions, set } = renderTab({ packages: [entry] }) + const assertOfficial = () => { + expect(document.querySelectorAll(`[data-plugin-package="${name}"]`)).toHaveLength(1) + expect(document.querySelector('[data-plugin-group="official"]')?.contains(document.querySelector(`[data-plugin-package="${name}"]`))).toBe(true) + expect(document.querySelector('[data-plugin-group="bundles"]')).toBeNull() + } + assertOfficial() + expect(screen.getByText(en.statusNotInstalled)).toBeTruthy() + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', title) })) + expect(actions.setEnabled).toHaveBeenCalledWith(name, true) + set({ packages: [{ ...entry, installed: true, availability: 'profile', enabled: true, removable: true, version: '2.0.0' }] }) + assertOfficial() + set({ packages: [entry] }) + assertOfficial() + }) + + it.each(['checking', 'starting'] as const)('keeps the catalog switch busy while its installation is %s', (phase) => { + const entry = catalog() + renderTab({ packages: [entry], install: { ...IDLE_INSTALL, phase, subject: { + status: 'accepted', kind: 'registry', name, version: '2.0.0', spec: `${name}@2.0.0`, registry: null, + bundle: true, selection: true, saveExact: true, + } } }) + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', title) }).hasAttribute('disabled')).toBe(true) + }) + + it('offers an explicit update and displays installed and target versions', () => { + const entry = { ...catalog(), installed: true, availability: 'profile' as const, removable: true, version: '1.0.0' } + const { actions } = renderTab({ packages: [entry] }) + expect(screen.getByText(en.statusUpdateAvailable)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).getByText('1.0.0')).toBeTruthy() + expect(within(detail).getByText('2.0.0')).toBeTruthy() + fireEvent.click(within(detail).getByRole('button', { name: en.updateLabel.replace('{name}', title) })) + expect(actions.update).toHaveBeenCalledWith(name) + }) + + it('distinguishes unreadable installed files and offers the existing update action to repair them', () => { + renderTab({ packages: [{ ...catalog(), installed: true, removable: true, error: { code: 'operation-error', diagnostic: 'Unreadable package files' } }] }) + expect(screen.getByText(en.statusMissingFiles)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).queryByText(en.statusNotInstalled)).toBeNull() + expect(within(detail).getByRole('button', { name: en.updateLabel.replace('{name}', title) })).toBeTruthy() + }) + + it('does not present an absent catalog package as built in or as an empty loaded component list', () => { + renderTab({ packages: [catalog()] }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).queryByText(en.sourceBuiltIn)).toBeNull() + expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', title) })).toBeNull() + expect(detail.querySelector('[data-plugin-rows]')).toBeNull() + }) +}) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 5685405894..d3e3dc2b20 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -15,6 +15,8 @@ const ROW_ENTRY = 'include:sidebar' as PluginEntryId const BUNDLE: BundleInfo = { name: 'dsh-better-sidebar', + official: false, + availability: 'profile', version: '0.16.0', description: 'A sidebar.', enabled: false, @@ -130,7 +132,7 @@ describe('packageView', () => { it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0', - installed: true, optional: false, removable: true, enabled: false, + installed: true, official: false, availability: 'profile', optional: false, removable: true, enabled: false, rows: [ { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null }, @@ -138,13 +140,13 @@ describe('packageView', () => { }) // A row the inventory no longer lists, a protected row, and a bundle the Host cannot read. const protectedBundle: BundleInfo = { - name: '@deepseek-ai/dsh-base', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: false, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }], overrides: [], } expect(packageView(protectedBundle, PLUGINS)).toEqual({ - name: '@deepseek-ai/dsh-base', installed: false, optional: false, removable: false, enabled: true, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: false, enabled: true, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, rows: [ { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' }, @@ -156,7 +158,7 @@ describe('packageView', () => { describe('sortPackages', () => { it('orders packages by the short name a person reads, not by the Host order or enablement', async () => { - const plain = { enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [] } + const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, rows: [], overrides: [] } const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' } const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', enabled: false } const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS)) @@ -651,7 +653,7 @@ describe('PluginManagerController', () => { }) it('refuses a spec the list already shows without asking the Host, and words what the Host refused', async () => { - const shipped: BundleInfo = { ...BUNDLE, name: '@deepseek-ai/dsh-official', installed: false, optional: true, removable: false } + const shipped: BundleInfo = { ...BUNDLE, name: '@deepseek-ai/dsh-official', installed: false, official: true, availability: 'installation', optional: true, removable: false } const { plugins, face, state, controller } = bench({ listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE, shipped]))), inspect: vi.fn() @@ -1812,3 +1814,116 @@ it('reports a successful install with only documented result fields', async () = input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new', }]]) }) + + +const CATALOG: BundleInfo = { + name: '@deepseek-ai/dsh-subagent-codex', official: true, availability: 'missing', installed: false, optional: false, + enabled: false, removable: false, rows: [], overrides: [], + installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' }, +} + +it('installs an absent Official entry directly at the host target and enables it through the normal installer', async () => { + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])) }) + await controller.load() + face.setEnabled(CATALOG.name, true) + await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledOnce() }) + expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.installBundle).toHaveBeenCalledWith(CATALOG.installTarget!.spec, { + enabled: true, saveExact: true, requestId: state().install.requestId, registry: null, + }) + expect(state().install.subject).toMatchObject({ selection: true, saveExact: true, version: '2.0.0' }) +}) + +it.each([true, false])('updates an Official entry with enabled=%s through the same exact installer', async (enabled) => { + const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled, removable: true } + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])), + installBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name, application: 'restart-required' })), + }) + await controller.load() + face.update(CATALOG.name) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(plugins.inspect).not.toHaveBeenCalled() + expect(plugins.installBundle).toHaveBeenCalledWith(CATALOG.installTarget!.spec, { + enabled, saveExact: true, requestId: state().install.requestId, registry: null, + }) + expect(state().install.restartRequired).toBe(true) +}) + +it('preserves the Official exact target and selection when script approval retries an installation', async () => { + const install = vi.fn().mockResolvedValueOnce(ok({ ...APPLIED, application: 'failed', stage: 'install', + error: { code: 'operation-error' }, pendingBuilds: ['native-provider'], + })).mockResolvedValueOnce(ok({ ...APPLIED, bundle: CATALOG.name })) + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])), installBundle: install }) + await controller.load() + face.setEnabled(CATALOG.name, true) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + face.approveBuildsAndRetry() + await vi.waitFor(() => { expect(install).toHaveBeenCalledTimes(2) }) + expect(plugins.inspect).not.toHaveBeenCalled() + expect(install).toHaveBeenLastCalledWith(CATALOG.installTarget!.spec, { + enabled: true, saveExact: true, requestId: state().install.requestId, registry: null, approvedBuilds: ['native-provider'], + }) +}) + +it('retains a failed Official install target while changing its registry', async () => { + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])), + installBundle: vi.fn().mockResolvedValueOnce(ok({ ...APPLIED, application: 'failed', stage: 'install', error: { code: 'operation-error' } })) + .mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name })), + }) + await controller.load() + face.setEnabled(CATALOG.name, true) + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + face.changeRegistry() + face.chooseRegistry({ kind: 'custom', url: CORP }) + face.runInstall() + await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledTimes(2) }) + expect(plugins.installBundle).toHaveBeenLastCalledWith(CATALOG.installTarget!.spec, { + enabled: true, saveExact: true, requestId: state().install.requestId, registry: CORP, + }) + expect(plugins.inspect).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + face.closeInstall() + face.setEnabled(CATALOG.name, true) + await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledTimes(3) }) + expect(plugins.installBundle).toHaveBeenLastCalledWith(CATALOG.installTarget!.spec, { + enabled: true, saveExact: true, requestId: state().install.requestId, registry: CORP, + }) +}) + +it('keeps Official disablement on the existing selection operation', async () => { + const { controller, plugins, face } = bench({ listBundles: vi.fn().mockResolvedValue(ok([{ ...CATALOG, enabled: true }])) }) + await controller.load() + face.setEnabled(CATALOG.name, false) + await vi.waitFor(() => { expect(plugins.setBundleEnabled).toHaveBeenCalledWith(CATALOG.name, false) }) + expect(plugins.installBundle).not.toHaveBeenCalled() +}) + +it('reopens one Official operation through registry preparation and installation instead of starting duplicates', async () => { + const registries = deferred>>() + const installing = deferred>>() + const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])), + registries: vi.fn().mockReturnValue(registries.promise), installBundle: vi.fn().mockReturnValue(installing.promise), + }) + await controller.load() + face.setEnabled(CATALOG.name, true) + expect(state().install.phase).toBe('checking') + face.setEnabled(CATALOG.name, true) + expect(plugins.registries).toHaveBeenCalledOnce() + registries.resolve(ok(REGISTRIES)) + await vi.waitFor(() => { expect(state().install.phase).toBe('starting') }) + face.update(CATALOG.name) + expect(plugins.installBundle).toHaveBeenCalledOnce() + installing.resolve(ok({ ...APPLIED, bundle: CATALOG.name })) + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) +}) + +it('ignores a stale update target and prevents a disposed tab from beginning an Official install', async () => { + const { controller, plugins, face } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG, BUNDLE])) }) + await controller.load() + face.update('removed') + face.update(BUNDLE.name) + expect(plugins.installBundle).not.toHaveBeenCalled() + controller.dispose() + face.setEnabled(CATALOG.name, true) + expect(plugins.installBundle).not.toHaveBeenCalled() +}) diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 6334e282b6..77d12ebcd3 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -25,19 +25,15 @@ Install this Profile Bundle when a delegated task should run as a fresh, unatten ## Use this package -Mount this provider when a delegation should run as a real Claude Code session in the parent's workspace. The common path is explicit: install the Bundle into a Profile, optionally configure the provider row, and expose it to the model through a delegation tool row. +Enable **Claude Code subagent** in the Web or Desktop Plugins page when a task needs a fresh native Claude Code session in the parent workspace. ### Installing the Bundle -Install the package into the target Profile, then restart that Profile. The installation brings the pinned Agent SDK and one compatible platform CLI payload into the Profile; the declared patch layer registers only the dormant provider and starts no Claude process. +The Official entry is visible offline. Enabling it installs this package at the running DSH version through the ordinary bundle installer and selects its profile layer. The layer registers the provider and adds `subagent_claude_code` to `standard`, `cordis`, and `ptc`; it starts no native process until delegation. Restart when the installer reports that one is required. -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code -dsh --profile -``` +Switching Off deselects the layer and leaves the package installed. Remove is a separate package operation. New Agents and subsequently reopened Sessions use the selected tool composition; live Agents retain their existing composition. -Removing the package withdraws the provider and its private runtime closure on the next Profile start. Installation controls Host availability, not model permission: the model can only reach the provider through a delegation tool row you compose. +Profiles without the full Web presets, including the shipped headless, SDK, and ACP profiles, must install this package as a profile dependency without selecting its bundle layer, then mount the provider and delegation tool explicitly. Existing provider-only consumers follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md). ### Configuration @@ -59,11 +55,14 @@ Removing the package withdraws the provider and its private runtime closure on t The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-claude-code) is the exhaustive source for every accepted field and its JSDoc. A configured `model` passes unchanged to every query from that provider instance; omission leaves native model selection in force. Credential-shaped ambient variables are removed before the explicit `env` overlay, so an API key intended for the child must be supplied there. The provider omits the SDK `settingSources` option, so Claude Code reads the host's normal user, project, and local settings relative to the selected child working directory. It does not copy or filter those files, create or modify login state, inspect `PATH`, or fall back to a host `claude` executable. + ### Exposing the tool -Each delegation tool row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. +The bundle adds `optional-tool-subagent-claude-code` to each full preset through scoped profile patches. The minimal preset and host tool catalog remain unchanged. For a custom profile without these preset targets, keep the package dependency and compose the provider and tool directly: ```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' - id: tool-subagent-claude name: '@deepseek-ai/dsh-tool-subagent' config: @@ -105,7 +104,7 @@ This section explains how the provider drives a real Claude Code CLI and where t | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, provider registration | | [`src/run.ts`](src/run.ts) | The SDK query lifecycle, result acceptance, and permission handling | | [`src/process.ts`](src/process.ts) | Managed-range termination escalation on disposal | -| [`cordis.patch.yml`](cordis.patch.yml) | The Profile patch layer that registers the dormant provider | +| [`cordis.patch.yml`](cordis.patch.yml) | The Profile layer that registers the provider and contributes preset delegation tools | ### Run flow diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 52a0c760eb..8e46c7ec0c 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -25,19 +25,15 @@ kind: "package-bundle" ## 使用本包 -当委派应以父级工作区中的真实 Claude Code 会话运行时,挂载本提供方。常用路径是显式的:把 Bundle 安装进 Profile,可选地配置提供方行,并通过委派工具行把它暴露给模型。 +当任务需要在父工作区中运行全新的原生 Claude Code 会话时,在 Web 或 Desktop 插件页启用 **Claude Code 子智能体**。 ### 安装 Bundle -把包安装进目标 Profile,然后重启该 Profile。安装会把锁定的 Agent SDK 与一个兼容的平台 CLI 载荷带入 Profile;声明的 patch 层只注册休眠的提供方,不启动任何 Claude 进程。 +官方条目可离线显示。启用时,普通 bundle 安装器会按当前 DSH 版本安装此包并选择其配置层。此层注册提供方,并为 `standard`、`cordis` 和 `ptc` 添加 `subagent_claude_code`;实际委派前不会启动原生进程。安装器提示需要重启时,请重启应用。 -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code -dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code -dsh --profile -``` +关闭只会取消选择配置层,保留已安装的包。移除是独立的包操作。新智能体和随后重新打开的会话使用所选工具组合;正在运行的智能体保留已有组合。 -移除包后,下一次 Profile 启动会撤回提供方及其私有运行时闭包。安装决定 Host 可用性,而不是模型权限:模型只能通过你组合的委派工具行触达提供方。 +不包含完整 Web 预设的配置(包括随附的 headless、SDK 和 ACP)必须将此包安装为配置依赖,而不选择其 bundle 层,然后显式挂载提供方和委派工具。原有仅提供方用法请遵循[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md)。 ### 配置 @@ -59,11 +55,14 @@ dsh --profile 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-claude-code)是每个受支持字段及其 JSDoc 的穷尽式真源。已配置的 `model` 会原样传给该提供方实例的每次 query;省略时保留原生模型选择。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被移除,因此供子进程使用的 API 密钥必须在该配置中显式提供。提供方省略 SDK 的 `settingSources` 选项,因此 Claude Code 会相对于所选子级工作目录 读取宿主机常规的用户、项目与本地设置。它不会复制或过滤这些文件、创建或修改登录状态、检查 `PATH`,也不会回退到宿主 `claude` 可执行文件。 + ### 暴露工具 -每个委派工具行指名一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent(智能体)暴露 `subagent_claude_code`。 +Bundle 通过作用于预设的配置补丁为每个完整预设添加 `optional-tool-subagent-claude-code`。minimal 预设和 Host 工具目录保持不变。对于不包含这些预设目标的自定义配置,保留包依赖并直接组合提供方和工具: ```yaml +- id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' - id: tool-subagent-claude name: '@deepseek-ai/dsh-tool-subagent' config: @@ -105,7 +104,7 @@ dsh --profile | [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、提供方注册 | | [`src/run.ts`](src/run.ts) | SDK query 生命周期、结果接受与权限处理 | | [`src/process.ts`](src/process.ts) | dispose(资源释放)时的 managed-range 逐级终止 | -| [`cordis.patch.yml`](cordis.patch.yml) | 注册休眠提供方的 Profile patch 层 | +| [`cordis.patch.yml`](cordis.patch.yml) | 注册提供方并添加预设委派工具的 Profile 配置层 | ### 运行流程 diff --git a/packages/subagent/subagent-claude-code/cordis.patch.yml b/packages/subagent/subagent-claude-code/cordis.patch.yml index 63c0319626..253ab724dd 100644 --- a/packages/subagent/subagent-claude-code/cordis.patch.yml +++ b/packages/subagent/subagent-claude-code/cordis.patch.yml @@ -1,6 +1,33 @@ -# This optional Profile layer registers the dormant Claude Code provider. Agent -# presets separately decide whether one session receives its delegation tool. - - insert: - id: subagent-claude-code name: '@deepseek-ai/dsh-subagent-claude-code' + +- preset: preset-standard + insert: + - id: optional-tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + +- preset: preset-cordis + insert: + - id: optional-tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed + +- preset: preset-ptc + insert: + - id: optional-tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + backgroundMode: one-shot + maxDepth: provider-managed diff --git a/packages/subagent/subagent-claude-code/icon.svg b/packages/subagent/subagent-claude-code/icon.svg new file mode 100644 index 0000000000..fe5471f9a5 --- /dev/null +++ b/packages/subagent/subagent-claude-code/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/subagent/subagent-claude-code/locale/en.json b/packages/subagent/subagent-claude-code/locale/en.json new file mode 100644 index 0000000000..e6896a21d7 --- /dev/null +++ b/packages/subagent/subagent-claude-code/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Claude Code subagent", + "description": "Delegate one task to Claude Code using its native authentication and settings." + } +} diff --git a/packages/subagent/subagent-claude-code/locale/zh.json b/packages/subagent/subagent-claude-code/locale/zh.json new file mode 100644 index 0000000000..439caceaf5 --- /dev/null +++ b/packages/subagent/subagent-claude-code/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Claude Code 子智能体", + "description": "使用 Claude Code 原生认证和设置委派单个任务。" + } +} diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 272e26b274..d529f2c8f5 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -19,12 +19,16 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json", + "./cordis.patch.yml": "./cordis.patch.yml" }, "files": [ "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "icon.svg", + "locale/*.json" ], "license": "MIT", "dsh": { @@ -46,7 +50,8 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "@modelcontextprotocol/sdk": "^1.29.0", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-tool-subagent": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -64,8 +69,8 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", - "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*" - } + }, + "icon": "./icon.svg" } diff --git a/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml b/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml index 1ccf9b6c8a..46ee07bd32 100644 --- a/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml +++ b/packages/subagent/subagent-claude-code/tests/fixtures/loader/claude-code.patch.yml @@ -1,5 +1,5 @@ -# Named Claude instance patch over the shipped headless profile and the package's -# public bundle patch. No model or product process starts in this test. +# Named Claude Code providers and tools over the shipped headless profile. +# No model or product process starts in this test. - id: headless-startup disabled: true @@ -10,6 +10,9 @@ - id: fixture name: './fixture.ts' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts b/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts index 2af70367e4..a00eb34bd6 100644 --- a/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts +++ b/packages/subagent/subagent-claude-code/tests/fixtures/loader/driver.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -/** Inspect the public Claude Code Bundle composition without invoking the product. */ +/** Inspect the public Claude Code provider composition without invoking the product. */ import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-subagent' @@ -7,9 +7,8 @@ import type {} from '@deepseek-ai/dsh-tools' import { bootProductionProfile } from '../../../../../test-support/loader-smoke/tests/fixtures/production-profile.ts' const configPath = process.argv[2] -const bundlePatchPath = process.argv[3] -if (configPath === undefined || bundlePatchPath === undefined) { - throw new Error('Claude Code Loader composition driver requires config and Bundle patch paths') +if (configPath === undefined) { + throw new Error('Claude Code Loader composition driver requires a config path') } let starts = 0 @@ -17,7 +16,6 @@ const ctx = await bootProductionProfile({ binName: 'subagent-claude-code-loader-composition', profile: 'headless', overlayPaths: [ - resolveConfigPath(bundlePatchPath, undefined), resolveConfigPath(configPath, undefined), ], prepare: (hostCtx) => { diff --git a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts index c2b303f548..7aee30b5a0 100644 --- a/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -13,24 +12,17 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'claude-code.patch.yml') -const packageDir = fileURLToPath(new URL('..', import.meta.url)) -const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { - dsh?: { bundle?: { patch?: string } } -} -const bundlePatch = manifest.dsh?.bundle?.patch -if (bundlePatch === undefined) throw new Error('Claude Code package must declare a Bundle patch') -const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('product-provider public Loader composition', () => { - it('loads the Bundle default, two named Claude instances, their tools, and Codex without starting either product', async () => { + it('loads the configured default, two named Claude instances, their tools, and Codex without starting either product', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'product-provider Loader composition', tempDirPrefix: 'dsh-product-provider-loader-', binScript: driver, libBinScript: driver, configPath, - binArgs: [configPath, bundlePatchPath], + binArgs: [configPath], tsconfigPath: repoTsconfig, processTimeoutMs: PRODUCTION_PROFILE_PROCESS_TIMEOUT_MS, env: { diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 6c596e2bac..74d154436e 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -343,7 +343,7 @@ afterEach(() => { }) describe('task admission and package contracts', () => { - it('ships one independently installable provider-only Bundle patch', () => { + it('ships the provider with scoped delegation tools for all full Web presets', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record @@ -393,14 +393,16 @@ describe('task admission and package contracts', () => { } const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) - const rows = Array.isArray(parsed) - ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) - : [] - expect(rows).toEqual([{ - id: 'subagent-claude-code', - name: '@deepseek-ai/dsh-subagent-claude-code', - }]) - expect(JSON.stringify(rows)).not.toContain('tool-subagent') + expect(parsed).toEqual([ + { insert: [{ id: 'subagent-claude-code', name: '@deepseek-ai/dsh-subagent-claude-code' }] }, + ...['standard', 'cordis', 'ptc'].map(preset => ({ + preset: `preset-${preset}`, + insert: [{ id: 'optional-tool-subagent-claude-code', name: '@deepseek-ai/dsh-tool-subagent', config: { + provider: 'claude-code', toolName: 'subagent_claude_code', backgroundMode: 'one-shot', maxDepth: 'provider-managed', + } }], + })), + ]) + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-tool-subagent', 'workspace:*') }) it('preserves text sequences and rejects empty, blank, and non-text tasks', () => { diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index d9d521442b..71a1aaba27 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Install `@deepseek-ai/dsh-subagent-codex` into a Profile when delegated work should run in a genuine, unattended Codex session in the parent Session's workspace. Each delegation uses a fresh isolated Codex thread for one self-contained text task and returns only its final answer or a safe failure diagnostic. Native Codex configuration and authentication remain authoritative, while `permissionMode` selects the non-interactive approval and sandbox behavior. The Bundle supplies a compatible native Codex payload, but it exposes no model capability until a delegation tool is configured. +Install `@deepseek-ai/dsh-subagent-codex` into a Profile when delegated work should run in a genuine, unattended Codex session in the parent Session's workspace. Each delegation uses a fresh isolated Codex thread for one self-contained text task and returns only its final answer or a safe failure diagnostic. Native Codex configuration and authentication remain authoritative, while `permissionMode` selects the non-interactive approval and sandbox behavior. The Bundle supplies a compatible native Codex payload and grants its delegation tool to the full Web presets. ## Table of Contents @@ -25,19 +25,15 @@ Install `@deepseek-ai/dsh-subagent-codex` into a Profile when delegated work sho ## Use this package -Mount this provider when a delegation should run as a real Codex session in the parent's workspace. The common path is explicit: install the Bundle into a Profile, optionally configure the provider row, and expose it to the model through a delegation tool row. +Enable **Codex subagent** in the Web or Desktop Plugins page when a task needs a fresh native Codex session in the parent workspace. ### Installing the Bundle -Install the package into the target Profile, then restart that Profile. The installation brings the official wrapper and one compatible native platform payload into the Profile; the declared patch layer registers only the dormant provider and starts no Codex process. +The Official entry is visible offline. Enabling it installs this package at the running DSH version through the ordinary bundle installer and selects its profile layer. The layer registers the provider and adds `subagent_codex` to `standard`, `cordis`, and `ptc`; it starts no native process until delegation. Restart when the installer reports that one is required. -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex -dsh --profile -``` +Switching Off deselects the layer and leaves the package installed. Remove is a separate package operation. New Agents and subsequently reopened Sessions use the selected tool composition; live Agents retain their existing composition. -Removing the package withdraws the provider and its private runtime closure on the next Profile start. Installation controls Host availability, not model permission: the model can only reach the provider through a delegation tool row you compose. +Profiles without the full Web presets, including the shipped headless, SDK, and ACP profiles, must install this package as a profile dependency without selecting its bundle layer, then mount the provider and delegation tool explicitly. Existing provider-only consumers follow the [upgrade guide](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md). ### Configuration @@ -57,11 +53,14 @@ Removing the package withdraws the provider and its private runtime closure on t The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-subagent-codex) is the exhaustive source for every accepted field and its JSDoc. A configured `model` passes unchanged on each ephemeral `thread/start`; omission leaves native model selection in force. The provider does not discover models, rewrite aliases, select `modelProvider` or `serviceTier`, or set a fallback. Credential-shaped ambient variables are removed before the explicit `env` overlay, so an API key intended for the child must be supplied there. + ### Exposing the tool -Each delegation tool row names one provider and needs its own `toolName`, so the model sees static tools rather than a dynamic provider selector. Full Agent Presets carry a matching default tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. +The bundle adds `optional-tool-subagent-codex` to each full preset through scoped profile patches. The minimal preset and host tool catalog remain unchanged. For a custom profile without these preset targets, keep the package dependency and compose the provider and tool directly: ```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: @@ -103,7 +102,7 @@ This section explains how the provider drives a real Codex app-server and where | [`src/index.ts`](src/index.ts) | Plugin entry: config schema, provider registration | | [`src/run.ts`](src/run.ts) | The run lifecycle, turn execution, result selection, and diagnostics | | [`src/wire.ts`](src/wire.ts) | The minimal app-server JSON-RPC wire implementation | -| [`cordis.patch.yml`](cordis.patch.yml) | The Profile patch layer that registers the dormant provider | +| [`cordis.patch.yml`](cordis.patch.yml) | The Profile layer that registers the provider and contributes preset delegation tools | ### Run flow diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 9a4904f823..403839a1f9 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -当委派工作需要在父会话工作区中的真实无人值守 Codex 会话内运行时,把 `@deepseek-ai/dsh-subagent-codex` 安装进 Profile。每次委派都会为一个自包含文本任务使用全新且隔离的 Codex 线程,并且只返回其最终答案或安全失败诊断。原生 Codex 配置和身份验证继续作为权威来源,而 `permissionMode` 选择非交互式审批和沙箱行为。Bundle 会提供兼容的原生 Codex 载荷,但只有配置委派工具后才会向模型公开相应能力。 +当委派工作需要在父会话工作区中的真实无人值守 Codex 会话内运行时,把 `@deepseek-ai/dsh-subagent-codex` 安装进 Profile。每次委派都会为一个自包含文本任务使用全新且隔离的 Codex 线程,并且只返回其最终答案或安全失败诊断。原生 Codex 配置和身份验证继续作为权威来源,而 `permissionMode` 选择非交互式审批和沙箱行为。Bundle 提供兼容的原生 Codex 载荷,并为完整 Web 预设添加委派工具。 ## 目录 @@ -25,19 +25,15 @@ kind: "package-bundle" ## 使用本包 -当委派应以父级工作区中的真实 Codex 会话运行时,挂载本提供方。常用路径是显式的:把 Bundle 安装进 Profile,可选地配置提供方行,并通过委派工具行把它暴露给模型。 +当任务需要在父工作区中运行全新的原生 Codex 会话时,在 Web 或 Desktop 插件页启用 **Codex 子智能体**。 ### 安装 Bundle -把包安装进目标 Profile,然后重启该 Profile。安装会把官方 wrapper 与一个兼容的原生平台载荷带入 Profile;声明的 patch 层只注册休眠的提供方,不启动任何 Codex 进程。 +官方条目可离线显示。启用时,普通 bundle 安装器会按当前 DSH 版本安装此包并选择其配置层。此层注册提供方,并为 `standard`、`cordis` 和 `ptc` 添加 `subagent_codex`;实际委派前不会启动原生进程。安装器提示需要重启时,请重启应用。 -```sh -dsh plugin --profile add @deepseek-ai/dsh-subagent-codex -dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex -dsh --profile -``` +关闭只会取消选择配置层,保留已安装的包。移除是独立的包操作。新智能体和随后重新打开的会话使用所选工具组合;正在运行的智能体保留已有组合。 -移除包后,下一次 Profile 启动会撤回提供方及其私有运行时闭包。安装决定 Host 可用性,而不是模型权限:模型只能通过你组合的委派工具行触达提供方。 +不包含完整 Web 预设的配置(包括随附的 headless、SDK 和 ACP)必须将此包安装为配置依赖,而不选择其 bundle 层,然后显式挂载提供方和委派工具。原有仅提供方用法请遵循[升级指南](../../../docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md)。 ### 配置 @@ -57,11 +53,14 @@ dsh --profile 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-subagent-codex)是每个受支持字段及其 JSDoc 的穷尽式真源。已配置的 `model` 会原样传给每个临时 `thread/start`;省略时保留原生模型选择。提供方不会发现模型、改写别名、选择 `modelProvider` 或 `serviceTier`,也不会设置 fallback。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被移除,因此供子进程使用的 API 密钥必须在该配置中显式提供。 + ### 暴露工具 -每个委派工具行指名一个提供方,并需要独立的 `toolName`,因此模型看到的是静态工具,而不是动态提供方选择器。完整 Agent Preset 携带对应的默认工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。 +Bundle 通过作用于预设的配置补丁为每个完整预设添加 `optional-tool-subagent-codex`。minimal 预设和 Host 工具目录保持不变。对于不包含这些预设目标的自定义配置,保留包依赖并直接组合提供方和工具: ```yaml +- id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' config: @@ -103,7 +102,7 @@ dsh --profile | [`src/index.ts`](src/index.ts) | 插件入口:config schema、提供方注册 | | [`src/run.ts`](src/run.ts) | 运行生命周期、轮次执行、结果选择与诊断 | | [`src/wire.ts`](src/wire.ts) | 最小的 app-server JSON-RPC 协议实现 | -| [`cordis.patch.yml`](cordis.patch.yml) | 注册休眠提供方的 Profile patch 层 | +| [`cordis.patch.yml`](cordis.patch.yml) | 注册提供方并添加预设委派工具的 Profile 配置层 | ### 运行流程 diff --git a/packages/subagent/subagent-codex/cordis.patch.yml b/packages/subagent/subagent-codex/cordis.patch.yml index fb64fdcb9d..881d811c74 100644 --- a/packages/subagent/subagent-codex/cordis.patch.yml +++ b/packages/subagent/subagent-codex/cordis.patch.yml @@ -1,5 +1,33 @@ -# Optional Profile Bundle: register the Codex provider on the Host plane only. -# Agent Presets grant the model-facing tool independently. - insert: - id: subagent-codex name: '@deepseek-ai/dsh-subagent-codex' + +- preset: preset-standard + insert: + - id: optional-tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + +- preset: preset-cordis + insert: + - id: optional-tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed + +- preset: preset-ptc + insert: + - id: optional-tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + backgroundMode: one-shot + maxDepth: provider-managed diff --git a/packages/subagent/subagent-codex/icon.svg b/packages/subagent/subagent-codex/icon.svg new file mode 100644 index 0000000000..fe5471f9a5 --- /dev/null +++ b/packages/subagent/subagent-codex/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/subagent/subagent-codex/locale/en.json b/packages/subagent/subagent-codex/locale/en.json new file mode 100644 index 0000000000..4181339622 --- /dev/null +++ b/packages/subagent/subagent-codex/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Codex subagent", + "description": "Delegate one task to Codex using its native authentication and settings." + } +} diff --git a/packages/subagent/subagent-codex/locale/zh.json b/packages/subagent/subagent-codex/locale/zh.json new file mode 100644 index 0000000000..dcbde663a3 --- /dev/null +++ b/packages/subagent/subagent-codex/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Codex 子智能体", + "description": "使用 Codex 原生认证和设置委派单个任务。" + } +} diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index d4e249a414..96300f97d2 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -19,12 +19,16 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json", + "./cordis.patch.yml": "./cordis.patch.yml" }, "files": [ "lib/index.js", "cordis.patch.yml", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "icon.svg", + "locale/*.json" ], "license": "MIT", "dsh": { @@ -44,7 +48,8 @@ "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-sdk-protocol": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "@openai/codex": "0.153.4" + "@openai/codex": "0.153.4", + "@deepseek-ai/dsh-tool-subagent": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -62,7 +67,7 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", - "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" - } + }, + "icon": "./icon.svg" } diff --git a/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml b/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml index aae62dbacd..81e2d1b000 100644 --- a/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml +++ b/packages/subagent/subagent-codex/tests/fixtures/loader/codex.patch.yml @@ -1,5 +1,5 @@ -# Named Codex instance patch over the shipped headless profile and the package's -# public bundle patch. No model or product process starts in this test. +# Named Codex providers and tools over the shipped headless profile. +# No model or product process starts in this test. - id: headless-startup disabled: true @@ -10,6 +10,9 @@ - id: fixture name: './fixture.ts' + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-codex-primary name: '@deepseek-ai/dsh-subagent-codex' config: diff --git a/packages/subagent/subagent-codex/tests/fixtures/loader/driver.ts b/packages/subagent/subagent-codex/tests/fixtures/loader/driver.ts index ca9f46a60c..ec69db5117 100644 --- a/packages/subagent/subagent-codex/tests/fixtures/loader/driver.ts +++ b/packages/subagent/subagent-codex/tests/fixtures/loader/driver.ts @@ -7,9 +7,8 @@ import type {} from '@deepseek-ai/dsh-tools' import { bootProductionProfile } from '../../../../../test-support/loader-smoke/tests/fixtures/production-profile.ts' const configPath = process.argv[2] -const bundlePatchPath = process.argv[3] -if (configPath === undefined || bundlePatchPath === undefined) { - throw new Error('subagent-codex Loader composition driver requires config and Bundle patch paths') +if (configPath === undefined) { + throw new Error('subagent-codex Loader composition driver requires a config path') } let starts = 0 @@ -17,7 +16,6 @@ const ctx = await bootProductionProfile({ binName: 'subagent-codex-loader-composition', profile: 'headless', overlayPaths: [ - resolveConfigPath(bundlePatchPath, undefined), resolveConfigPath(configPath, undefined), ], prepare: (hostCtx) => { diff --git a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts index 3ec3e71cea..1c996a0643 100644 --- a/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts +++ b/packages/subagent/subagent-codex/tests/loader-composition.e2e.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -13,24 +12,17 @@ const fixtureDir = fileURLToPath(new URL( )) const driver = join(fixtureDir, 'driver.ts') const configPath = join(fixtureDir, 'codex.patch.yml') -const packageDir = fileURLToPath(new URL('..', import.meta.url)) -const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { - dsh?: { bundle?: { patch?: string } } -} -const bundlePatch = manifest.dsh?.bundle?.patch -if (bundlePatch === undefined) throw new Error('Codex package must declare a Bundle patch') -const bundlePatchPath = join(packageDir, bundlePatch) const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) describe('Codex provider public Loader composition', () => { - it('loads the Bundle default, two named instances, their tools, and job controls without starting Codex', async () => { + it('loads the configured default, two named instances, their tools, and job controls without starting Codex', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'subagent-codex Loader composition', tempDirPrefix: 'dsh-subagent-codex-loader-', binScript: driver, libBinScript: driver, configPath, - binArgs: [configPath, bundlePatchPath], + binArgs: [configPath], tsconfigPath: repoTsconfig, processTimeoutMs: PRODUCTION_PROFILE_PROCESS_TIMEOUT_MS, env: { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index a3e355552b..926f5bd30f 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -359,7 +359,7 @@ function expectedFailureDiagnostic( } describe('task admission and package contracts', () => { - it('ships one independently installable provider-only Bundle patch', () => { + it('ships the provider with scoped delegation tools for all full Web presets', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { dependencies?: Record @@ -406,14 +406,16 @@ describe('task admission and package contracts', () => { } const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8')) - const rows = Array.isArray(parsed) - ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? []) - : [] - expect(rows).toEqual([{ - id: 'subagent-codex', - name: '@deepseek-ai/dsh-subagent-codex', - }]) - expect(JSON.stringify(rows)).not.toContain('tool-subagent') + expect(parsed).toEqual([ + { insert: [{ id: 'subagent-codex', name: '@deepseek-ai/dsh-subagent-codex' }] }, + ...['standard', 'cordis', 'ptc'].map(preset => ({ + preset: `preset-${preset}`, + insert: [{ id: 'optional-tool-subagent-codex', name: '@deepseek-ai/dsh-tool-subagent', config: { + provider: 'codex', toolName: 'subagent_codex', backgroundMode: 'one-shot', maxDepth: 'provider-managed', + } }], + })), + ]) + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-tool-subagent', 'workspace:*') }) it('accepts one or more text blocks and rejects empty or non-text tasks', () => { diff --git a/scripts/gen-official-bundle-catalog.spec.ts b/scripts/gen-official-bundle-catalog.spec.ts new file mode 100644 index 0000000000..623c5d18ae --- /dev/null +++ b/scripts/gen-official-bundle-catalog.spec.ts @@ -0,0 +1,107 @@ +/** Offline Official catalog ownership, completeness, and freshness checks. */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundles.ts' +import { generateOfficialBundleCatalog, renderOfficialBundleCatalog, syncOfficialBundleCatalog } from './gen-official-bundle-catalog.ts' + +const roots: string[] = [] +const output = 'packages/boot/app-boot/src/official-bundles.generated.ts' + +function write(root: string, path: string, content: unknown): void { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), typeof content === 'string' ? content : `${JSON.stringify(content)}\n`) +} + +function directory(name: string): string { return `packages/subagent/${name.slice('@deepseek-ai/dsh-'.length)}` } + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-official-catalog-')) + roots.push(root) + write(root, 'apps/cli/package.json', { name: '@deepseek-ai/dsh' }) + for (const name of ON_DEMAND_BUNDLES) { + const dir = directory(name) + write(root, `${dir}/package.json`, { + name, type: 'module', version: '1.2.3', icon: './icon.svg', dsh: { bundle: { patch: './cordis.patch.yml' } }, + exports: { '.': './must-not-run.js', './package.json': './package.json', './locale/*.json': './locale/*.json' }, + }) + write(root, `${dir}/must-not-run.js`, "throw new Error('metadata must not evaluate a provider')") + write(root, `${dir}/icon.svg`, '') + write(root, `${dir}/locale/en.json`, { meta: { title: 'Native agent', description: 'Delegate a task.' } }) + write(root, `${dir}/locale/zh.json`, { meta: { title: '原生智能体', description: '委派任务。' } }) + } + write(root, output, '') + return root +} + +function updateManifest(root: string, name: string, changes: Record): void { + const path = `${directory(name)}/package.json` + const manifest = JSON.parse(readFileSync(join(root, path), 'utf8')) as Record + write(root, path, { ...manifest, ...changes }) +} + +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +describe('Official bundle catalog generation', () => { + it('reads package resources without evaluating native provider entries or pinning a version', () => { + const entries = generateOfficialBundleCatalog(fixture()) + expect(entries.map(entry => entry.packageName)).toEqual(ON_DEMAND_BUNDLES) + expect(entries[0]?.meta).toEqual({ + title: { en: 'Native agent', zh: '原生智能体' }, description: { en: 'Delegate a task.', zh: '委派任务。' }, + icon: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, + }) + expect(renderOfficialBundleCatalog(entries)).not.toContain('1.2.3') + }) + + it.each(['private', 'not-bundle', 'missing', 'duplicate'])('rejects %s catalog membership', (kind) => { + const root = fixture() + const name = ON_DEMAND_BUNDLES[0]! + if (kind === 'private') updateManifest(root, name, { private: true }) + if (kind === 'not-bundle') updateManifest(root, name, { dsh: {} }) + if (kind === 'missing') rmSync(join(root, directory(name)), { recursive: true }) + if (kind === 'duplicate') write(root, 'packages/subagent/duplicate/package.json', { name }) + expect(() => generateOfficialBundleCatalog(root)).toThrow(/public DSH bundle|duplicate workspace/) + }) + + it.each([false, true])('rejects an on-demand provider in the installed dependency closure (indirect=%s)', (indirect) => { + const root = fixture() + const name = ON_DEMAND_BUNDLES[0]! + write(root, 'apps/cli/package.json', { name: '@deepseek-ai/dsh', dependencies: { [indirect ? '@deepseek-ai/dsh-layer' : name]: '*' } }) + if (indirect) write(root, 'packages/bundle/layer/package.json', { name: '@deepseek-ai/dsh-layer', dependencies: { [name]: '*' } }) + expect(() => generateOfficialBundleCatalog(root)).toThrow('must not be installed by the DSH distribution') + }) + + it('requires distinct nonempty catalog admission', () => { + const root = fixture() + expect(() => generateOfficialBundleCatalog(root, [])).toThrow('distinct package names') + expect(() => generateOfficialBundleCatalog(root, [ON_DEMAND_BUNDLES[0]!, ON_DEMAND_BUNDLES[0]!])).toThrow('distinct package names') + }) + + it.each(['icon', 'en', 'zh'])('rejects missing %s metadata', (resource) => { + const root = fixture() + const dir = directory(ON_DEMAND_BUNDLES[0]!) + rmSync(join(root, dir, resource === 'icon' ? 'icon.svg' : `locale/${resource}.json`)) + expect(() => generateOfficialBundleCatalog(root)).toThrow() + }) + + it.each(['en', 'zh'])('requires explicit %s fields instead of metadata fallback text', (language) => { + const root = fixture() + write(root, `${directory(ON_DEMAND_BUNDLES[0]!)}/locale/${language}.json`, { meta: { description: 'Only a description.' } }) + expect(() => generateOfficialBundleCatalog(root)).toThrow(`explicit ${language} title and description`) + }) + + it('rejects stale output when package-owned display text changes', () => { + const root = fixture() + rmSync(join(root, output)) + syncOfficialBundleCatalog(root, false) + expect(() => { syncOfficialBundleCatalog(root, true) }).not.toThrow() + write(root, `${directory(ON_DEMAND_BUNDLES[0]!)}/locale/en.json`, { meta: { title: 'Changed', description: 'Delegate a task.' } }) + const before = readFileSync(join(root, output), 'utf8') + expect(() => { syncOfficialBundleCatalog(root, true) }).toThrow('is stale') + expect(readFileSync(join(root, output), 'utf8')).toBe(before) + syncOfficialBundleCatalog(root, false) + expect(readFileSync(join(root, output), 'utf8')).toContain('Changed') + }) +}) diff --git a/scripts/gen-official-bundle-catalog.ts b/scripts/gen-official-bundle-catalog.ts new file mode 100644 index 0000000000..3db88369cb --- /dev/null +++ b/scripts/gen-official-bundle-catalog.ts @@ -0,0 +1,124 @@ +/** Embed package-owned display metadata for Official bundles whose providers install on demand. */ + +import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import ts from 'typescript' +import type { LocalizedText } from '../packages/util/package-manifest/src/types.ts' +import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundle-packages.ts' +import type { OfficialBundleCatalogEntry } from '../packages/boot/app-boot/src/official-bundles.ts' +import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot/src/package-meta.ts' + +const OUTPUT = 'packages/boot/app-boot/src/official-bundles.generated.ts' + +/** + * Read complete metadata without importing or activating any provider code. + * @param root - Repository root containing the catalog's public packages. + * @param names - Catalog admission list; defaults to the installation-owned list. + * @returns Offline entries in admission order, with metadata from the owning packages. + */ +export function generateOfficialBundleCatalog(root: string, names: readonly string[] = ON_DEMAND_BUNDLES): OfficialBundleCatalogEntry[] { + if (names.length === 0 || new Set(names).size !== names.length) throw new Error('Official on-demand catalog must contain distinct package names') + const manifests = new Map() + for (const path of globSync(['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json'], { cwd: root }).map(path => path.replaceAll('\\', '/')).sort()) { + const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as { + name: string + private?: boolean + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + dsh?: { bundle?: { patch?: unknown } } + } + if (manifests.has(manifest.name)) throw new Error(`duplicate workspace package ${manifest.name}`) + const runtime = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const + manifests.set(manifest.name, { path, private: manifest.private === true, bundle: manifest.dsh?.bundle?.patch !== undefined, + runtime: runtime.flatMap(section => Object.keys(manifest[section] ?? {})) }) + } + const visited = new Set() + const checkInstallation = (name: string): void => { + if (visited.has(name)) return + visited.add(name) + if (names.includes(name)) throw new Error(`${name}: on-demand providers must not be installed by the DSH distribution`) + for (const dependency of manifests.get(name)?.runtime ?? []) checkInstallation(dependency) + } + if (!manifests.has('@deepseek-ai/dsh')) throw new Error('Official catalog requires the DSH installation manifest') + checkInstallation('@deepseek-ai/dsh') + return names.map((packageName) => { + const entry = manifests.get(packageName) + if (entry === undefined || !entry.path.startsWith('packages/') || !packageName.startsWith('@deepseek-ai/dsh-') || entry.private || !entry.bundle) { + throw new Error(`${packageName}: Official catalog entries must be public DSH bundle packages`) + } + const parentURL = pathToFileURL(resolve(root, entry.path)).href + for (const language of ['en', 'zh']) { + const file = resolvePluginResource(`${packageName}/locale/${language}.json`, parentURL) + const dictionary = JSON.parse(readFileSync(file, 'utf8')) as { meta?: { title?: unknown; description?: unknown } } + if (typeof dictionary.meta?.title !== 'string' || dictionary.meta.title.trim() === '' + || typeof dictionary.meta.description !== 'string' || dictionary.meta.description.trim() === '') { + throw new Error(`${packageName}: Official metadata requires explicit ${language} title and description`) + } + } + const meta = readPluginMeta(packageName, parentURL) + if (meta?.error !== undefined) throw new Error(`${packageName}: ${meta.error}`) + if (meta?.icon === undefined || typeof meta.title !== 'object' || typeof meta.description !== 'object') { + throw new Error(`${packageName}: Official catalog entries require an icon and localized title and description`) + } + for (const value of [meta.title, meta.description]) { + for (const language of ['en', 'zh']) { + if (typeof value[language] !== 'string' || value[language].trim() === '') { + throw new Error(`${packageName}: Official metadata requires ${language} text`) + } + } + } + return { packageName, meta: { title: meta.title, description: meta.description, icon: meta.icon } } + }) +} + +/** + * Render a source-plane catalog with no package imports or pinned runtime version. + * @param entries - Validated metadata in catalog order. + * @returns Deterministic TypeScript source for app-boot's generated module. + */ +export function renderOfficialBundleCatalog(entries: readonly OfficialBundleCatalogEntry[]): string { + return '/** Generated offline Official bundle metadata. Run pnpm gen-official-bundle-catalog; do not edit. */\n\n' + + "import type { OfficialBundleCatalogEntry } from './official-bundles.ts'\n\n" + + '/** Official on-demand entries, independent of installed packages and registry availability. */\n' + + 'export const OFFICIAL_ON_DEMAND_CATALOG: readonly OfficialBundleCatalogEntry[] = [\n' + + entries.map(entry => ' {\n' + + ` packageName: ${quote(entry.packageName)},\n` + + ' meta: {\n' + + ` title: ${localized(entry.meta.title)},\n` + + ` description: ${localized(entry.meta.description)},\n` + + ` icon: ${quote(entry.meta.icon)},\n` + + ' },\n },\n').join('') + + ']\n' +} + +const printer = ts.createPrinter() +const sourceFile = ts.createSourceFile('catalog.ts', '', ts.ScriptTarget.Latest) + +function quote(value: string): string { + return printer.printNode(ts.EmitHint.Expression, ts.factory.createStringLiteral(value, true), sourceFile) +} + +function localized(value: LocalizedText): string { + if (typeof value === 'string') return quote(value) + return `{ ${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([language, text]) => `${quote(language)}: ${quote(text)}`).join(', ')} }` +} + +/** + * Write or freshness-check metadata against the owning package resources. + * @param root - Repository root containing source and metadata resources. + * @param check - Reject drift without changing the generated file. + */ +export function syncOfficialBundleCatalog(root: string, check: boolean): void { + const source = renderOfficialBundleCatalog(generateOfficialBundleCatalog(root)) + const path = resolve(root, OUTPUT) + if (check) { + if (readFileSync(path, 'utf8') !== source) throw new Error(`${OUTPUT} is stale; run pnpm gen-official-bundle-catalog`) + } else writeFileSync(path, source) +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + syncOfficialBundleCatalog(resolve(import.meta.dirname, '..'), process.argv.includes('--check')) + console.log('Official on-demand catalog matches package-owned metadata.') +} diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 73cc03a389..af6bcde4cf 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts' +import { ON_DEMAND_BUNDLES } from '../../packages/boot/app-boot/src/official-bundles.ts' import { releaseFamily, type ReleaseMember } from './families.ts' import { compareVersions, nextVendorVersion, planShared, reachesPayload } from './bump.ts' @@ -238,6 +239,52 @@ describe('release families', () => { ]) }) + it('publishes on-demand catalog members before dsh without adding installed dependencies', () => { + const dsh = member('apps/cli', '@deepseek-ai/dsh') + const providers = ON_DEMAND_BUNDLES.map(name => member(`packages/subagent/${name}`, name)) + const plan = releaseFamily('dsh').publishOrder([dsh, ...providers]) + expect(plan.order.map(entry => entry.name)).toEqual([...ON_DEMAND_BUNDLES, dsh.name]) + expect(dsh.manifest).toEqual({}) + expect(plan.droppedPeerEdges).toEqual([]) + }) + + it('rejects an advertised provider missing from the public release family', () => { + expect(() => releaseFamily('dsh').publishOrder([member('apps/cli', '@deepseek-ai/dsh')])) + .toThrow('Official catalog prerequisite') + }) + + it('keeps catalog prerequisites ahead of a dsh peer declaration', () => { + const dsh = member('apps/cli', '@deepseek-ai/dsh') + const providers = ON_DEMAND_BUNDLES.map(name => member(`packages/subagent/${name}`, name, { + peerDependencies: { '@deepseek-ai/dsh': 'workspace:*' }, + })) + const plan = releaseFamily('dsh').publishOrder([dsh, ...providers]) + expect(plan.order.map(entry => entry.name)).toEqual([...ON_DEMAND_BUNDLES, dsh.name]) + expect(plan.droppedPeerEdges).toEqual(ON_DEMAND_BUNDLES.map(name => ({ consumer: name, peer: dsh.name }))) + }) + + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( + 'requires exact packed DSH %s while preserving external ranges', (section) => { + const pkg = member('packages/example/provider', '@deepseek-ai/dsh-provider') + const family = releaseFamily('dsh') + const manifest = { name: pkg.name, version: pkg.version, [section]: { '@deepseek-ai/dsh-tools': pkg.version, external: '^4.0.0' } } + expect(() => { family.validatePackedManifest(pkg, manifest) }).not.toThrow() + for (const range of ['workspace:*', '^0.0.1', '~0.0.1', '0.0.2', '*']) { + expect(() => { family.validatePackedManifest(pkg, { ...manifest, [section]: { '@deepseek-ai/dsh-tools': range } }) }) + .toThrow(`packed ${section}.@deepseek-ai/dsh-tools must equal`) + } + }, + ) + + it('checks packed identities and preserves independent vendor versions', () => { + const pkg = member('vendor/example', '@deepseek-ai/example') + const family = releaseFamily('vendor') + expect(() => { family.validatePackedManifest(pkg, { name: pkg.name, version: pkg.version, dependencies: { '@deepseek-ai/dsh-tools': '^4.0.0' } }) }) + .not.toThrow() + expect(() => { family.validatePackedManifest(pkg, { name: '@deepseek-ai/other', version: pkg.version }) }).toThrow('packed identity') + expect(() => { family.validatePackedManifest(pkg, { name: pkg.name, version: '0.0.2' }) }).toThrow('packed identity') + }) + it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => { const dsh = releaseFamily('dsh') const members = [ diff --git a/scripts/release/families.ts b/scripts/release/families.ts index 0a7d808d96..1f821ffd7e 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -16,6 +16,7 @@ import { readClientBuildRecord, } from '../client-build-environment.ts' import { validateTarballPayload } from '../publication-payload.ts' +import { ON_DEMAND_BUNDLES } from '../../packages/boot/app-boot/src/official-bundle-packages.ts' /** * Dependency sections a consumer must publish after, because npm resolves them @@ -248,6 +249,13 @@ export abstract class ReleaseFamily { return { order: ordered, droppedPeerEdges } } + /** + * Packages that must already be published before this member can advertise them. + * @param _member - Member being published. + * @returns Release prerequisites that do not become installed dependencies. + */ + protected publicationPrerequisites(_member: ReleaseMember): readonly string[] { return [] } + /** * The family members one member declares in the given sections. * @param member - the dependent member. @@ -269,6 +277,13 @@ export abstract class ReleaseFamily { if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency) } } + if (sections.includes('dependencies')) { + for (const name of this.publicationPrerequisites(member)) { + const prerequisite = byName.get(name) + if (prerequisite === undefined) throw new Error(`${member.name}: Official catalog prerequisite ${name} is missing from the public release family`) + if (!edges.some(edge => edge.name === name)) edges.push(prerequisite) + } + } return edges.sort((left, right) => left.name.localeCompare(right.name)) } @@ -311,6 +326,17 @@ export abstract class ReleaseFamily { */ abstract validatePayload(member: ReleaseMember, files: readonly string[]): void + /** + * Check the identity in the packed manifest before publishing its bytes. + * @param member - Source member selected for this release. + * @param manifest - Manifest read from its packed tarball. + */ + validatePackedManifest(member: ReleaseMember, manifest: Readonly>): void { + if (manifest.name !== member.name || manifest.version !== member.version) { + throw new Error(`${member.name}: packed identity must match ${member.name}@${member.version}`) + } + } + /** * The executable that proves this family's artifacts install and run, or * `undefined` for a family that publishes no executable. @@ -369,6 +395,26 @@ class DshFamily extends ReleaseFamily { validateTarballPayload(files, member.name) } + /** Publish every advertised on-demand package before the installation that offers it. */ + protected override publicationPrerequisites(member: ReleaseMember): readonly string[] { + return member.name === '@deepseek-ai/dsh' ? ON_DEMAND_BUNDLES : [] + } + + /** Require the packed DSH runtime graph to stay on the shared exact release version. */ + override validatePackedManifest(member: ReleaseMember, manifest: Readonly>): void { + super.validatePackedManifest(member, manifest) + for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + const dependencies = manifest[section] + if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue + for (const [name, range] of Object.entries(dependencies)) { + if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue + if (range !== member.version) { + throw new Error(`${member.name}: packed ${section}.${name} must equal ${member.version}, got ${String(range)}`) + } + } + } + } + readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' } } diff --git a/scripts/release/pack.spec.ts b/scripts/release/pack.spec.ts index aa7314cb5e..b7a102122f 100644 --- a/scripts/release/pack.spec.ts +++ b/scripts/release/pack.spec.ts @@ -320,6 +320,19 @@ describe('release pack CLI', () => { expect(existsSync(join(root, 'packed', PUBLISH_ORDER_FILE))).toBe(false) }) + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( + 'rejects nonexact DSH %s in the produced tarball before recording publication', async (section) => { + const root = fixture() + packageFixture(root, 'packages/util/probe', '@deepseek-ai/dsh-probe', { [section]: { '@deepseek-ai/dsh-library': '^1.2.3' } }) + write(join(root, 'apps/web/dist/index.html'), '
') + writeClientBuildRecord(root, officialClientBuildEnvironment(root, { DSH_CLIENT_COMMIT_HASH: commit })) + const result = await runPack(root, ['--family', 'dsh']) + expect(result.status).not.toBe(0) + expect(result.stderr).toContain(`packed ${section}.@deepseek-ai/dsh-library must equal 1.2.3`) + expect(existsSync(join(root, 'packed', PUBLISH_ORDER_FILE))).toBe(false) + }, + ) + it('stops the ready pack hook and its stalled descendant before fixture cleanup', async () => { const root = fixture() packageFixture(root, 'vendor/stalled', '@deepseek-ai/stalled', { scripts: { prepack: 'node stalled.cjs' } }) diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index c840f8e762..4fce483966 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -14,7 +14,7 @@ import { parseArgs } from 'node:util' import { pnpmInvocation } from '../pnpm-invocation.ts' import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' import { isEntry, runConcurrent } from './process.ts' -import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' +import { PUBLISH_ORDER_FILE, packedManifest, tarballFiles } from './tarball.ts' /** Where pack output lands when `--out` is omitted. */ const DEFAULT_OUTPUT = 'dist/npm' @@ -31,6 +31,7 @@ function validatePackedMember(family: ReleaseFamily, member: ReleaseMember, dest const tarball = join(destination, filename) if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`) family.validatePayload(member, tarballFiles(tarball)) + family.validatePackedManifest(member, packedManifest(tarball)) return filename } diff --git a/scripts/release/tarball.ts b/scripts/release/tarball.ts index fc2b6dc973..0e34d29bd4 100644 --- a/scripts/release/tarball.ts +++ b/scripts/release/tarball.ts @@ -32,14 +32,23 @@ export function tarballFiles(tarball: string): string[] { } /** - * Read a packed tarball's own manifest. - * @param tarball - absolute tarball path. + * Read the manifest contained in a packed tarball, without consulting the checkout. + * @param tarball - Absolute tarball path. + * @returns The packed package.json object. + */ +export function packedManifest(tarball: string): Record { + const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', basename(tarball), 'package/package.json'], { cwd: dirname(tarball) })) + if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error(`${tarball} has no manifest`) + return manifest as Record +} + +/** + * Read a packed tarball's own identity. + * @param tarball - Absolute tarball path. * @returns The name and version the tarball declares. */ export function packedIdentity(tarball: string): PackedIdentity { - const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', basename(tarball), 'package/package.json'], { cwd: dirname(tarball) })) - if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`) - const { name, version } = manifest as Record + const { name, version } = packedManifest(tarball) if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`) return { name, version } } diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index aac0cd7252..7a3a6fbc5f 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -354,7 +354,7 @@ describe('gate graph validation', () => { const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id)) expect(ids).toEqual([ - 'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'package-dependencies', 'application-entrypoints', + 'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'official-bundle-catalog', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'node-next-types', 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher', 'plugin-record-callers', 'no-unknown-casts', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ee355e4595..b18eb31c68 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -346,6 +346,7 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('default-product-isolation', 'verify-default-product-isolation', { label: 'default product isolation' }), pnpmScript('product-use', 'verify-product-use', { label: 'product package classification' }), + pnpmScript('official-bundle-catalog', 'verify-official-bundle-catalog', { label: 'Official bundle catalog' }), pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), @@ -760,6 +761,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('default-product-isolation', 'verify-default-product-isolation', { label: 'default product isolation' }), pnpmScript('product-use', 'verify-product-use', { label: 'product package classification' }), + pnpmScript('official-bundle-catalog', 'verify-official-bundle-catalog', { label: 'Official bundle catalog' }), pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), pnpmScript('application-entrypoints', 'verify-application-entrypoints', { label: 'application entrypoints' }), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),