From de8b10f8abc3c48546ab11663c8c09576709bb59 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 18 Sep 2026 20:57:29 +0800 Subject: [PATCH 01/10] fix(browser-use): share the installation's scope and MCP client instances A profile installs @deepseek-ai/dsh-experimental-browser-use-runtime from npm beside the dsh installation, and that package declared @deepseek-ai/dsh-scope and @deepseek-ai/dsh-mcp-client as dependencies, so the install carried its own copy of both. dsh-scope mints its scope-tag symbol per module instance, so the copy's createScope tags a context the host's scopeOf cannot read. Every Agent's MCP tools then registered in the global tool layer: the first Agent succeeded and each later Agent's tool synchronization hit a name conflict, which failOnStartupError turned into a failed creation. Both are now peer dependencies (plus dev), which is how every other consumer of ctx.tools and dsh-scope declares them and how a profile install reaches the installation's single instance through the profiles module fallback. schemastery stays a dependency: it carries no instance-local state. Fixes #4573 --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 12 +- docs/module-graph.zh.md | 12 +- .../browser-use-runtime/README.i18n.yaml | 4 +- .../browser-use-runtime/README.md | 1 + .../browser-use-runtime/README.zh.md | 1 + .../browser-use-runtime/package.json | 6 +- .../tests/host-runtime-duplication.spec.ts | 128 ++++++++++++++++++ .../tests/shared-host-runtimes.spec.ts | 36 +++++ pnpm-lock.yaml | 12 +- 10 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 packages/experimental/browser-use-runtime/tests/host-runtime-duplication.spec.ts create mode 100644 packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 78f2897d67..97713402e0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2e2899c3a74e1d2c5fe50ab137408e7ed3beca82 -module-graph.zh.md: 49291ba55842462e5d532cce1de4f3803610d908 +module-graph.md: 2b8eeeac4f49fe8c94b1056fd2b57f57fd0a241f +module-graph.zh.md: 804f227cc8e43478caff2da02b664650bdd2f9fb diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e2899c3a7..2b8eeeac4f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -840,10 +840,6 @@ flowchart TD pkg_experimental_browser_use_playwright_mcp --> pkg_browser_use pkg_experimental_browser_use_playwright_mcp --> pkg_system_prompt pkg_experimental_browser_use_playwright_mcp --> pkg_tools - pkg_experimental_browser_use_runtime --> pkg_agent - pkg_experimental_browser_use_runtime --> pkg_browser_use - pkg_experimental_browser_use_runtime --> pkg_system_prompt - pkg_experimental_browser_use_runtime --> pkg_tools pkg_experimental_browser_use_stagehand_native --> pkg_agent pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt @@ -1115,6 +1111,12 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_experimental_browser_use_runtime --> pkg_agent + pkg_experimental_browser_use_runtime --> pkg_browser_use + pkg_experimental_browser_use_runtime --> pkg_mcp_client + pkg_experimental_browser_use_runtime --> pkg_scope + pkg_experimental_browser_use_runtime --> pkg_system_prompt + pkg_experimental_browser_use_runtime --> pkg_tools pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session @@ -1520,7 +1522,6 @@ flowchart TD | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | @@ -1567,6 +1568,7 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 49291ba558..804f227cc8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -842,10 +842,6 @@ flowchart TD pkg_experimental_browser_use_playwright_mcp --> pkg_browser_use pkg_experimental_browser_use_playwright_mcp --> pkg_system_prompt pkg_experimental_browser_use_playwright_mcp --> pkg_tools - pkg_experimental_browser_use_runtime --> pkg_agent - pkg_experimental_browser_use_runtime --> pkg_browser_use - pkg_experimental_browser_use_runtime --> pkg_system_prompt - pkg_experimental_browser_use_runtime --> pkg_tools pkg_experimental_browser_use_stagehand_native --> pkg_agent pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt @@ -1117,6 +1113,12 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter + pkg_experimental_browser_use_runtime --> pkg_agent + pkg_experimental_browser_use_runtime --> pkg_browser_use + pkg_experimental_browser_use_runtime --> pkg_mcp_client + pkg_experimental_browser_use_runtime --> pkg_scope + pkg_experimental_browser_use_runtime --> pkg_system_prompt + pkg_experimental_browser_use_runtime --> pkg_tools pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session @@ -1522,7 +1524,6 @@ flowchart TD | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | @@ -1569,6 +1570,7 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | diff --git a/packages/experimental/browser-use-runtime/README.i18n.yaml b/packages/experimental/browser-use-runtime/README.i18n.yaml index aea1c907fe..f8b6e7efb5 100644 --- a/packages/experimental/browser-use-runtime/README.i18n.yaml +++ b/packages/experimental/browser-use-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/browser-use-runtime/README.md -README.md: 41c2b627d1ecfe6f3ebba974f717245220cc6246 -README.zh.md: 4b5a58e7d2772d7fc56a7d3f8572f5c84b5641e1 +README.md: f29b5e3e2a804ccb24e7bc2bd795cdf8b095cead +README.zh.md: 65f5a912fc904a8eb38c7e3d5cd712467d7c166b diff --git a/packages/experimental/browser-use-runtime/README.md b/packages/experimental/browser-use-runtime/README.md index 41c2b627d1..f29b5e3e2a 100644 --- a/packages/experimental/browser-use-runtime/README.md +++ b/packages/experimental/browser-use-runtime/README.md @@ -85,6 +85,7 @@ Providers remain responsible for the browser operations they supply. - **Attachment scope** — exclusive ownership applies to one resource manager, not separate providers, processes, or external browser clients. - **Cancellation** — abort signals and connection closure cannot undo browser actions already delivered. An upstream operation that ignores both can delay cleanup. - **Recovery** — a failed close retains ownership; this manager does not retry disposal or restore browser state from the Session log. +- **Shared host runtimes** — a profile installs this package beside the dsh installation, so `@deepseek-ai/dsh-scope` and `@deepseek-ai/dsh-mcp-client` stay peer dependencies. A dependency edge ships a second `dsh-scope` copy whose scope tags host registries cannot read: each Agent's MCP tools would register in the global tool layer and the second Agent's creation would fail. ### Dev Note diff --git a/packages/experimental/browser-use-runtime/README.zh.md b/packages/experimental/browser-use-runtime/README.zh.md index 4b5a58e7d2..65f5a912fc 100644 --- a/packages/experimental/browser-use-runtime/README.zh.md +++ b/packages/experimental/browser-use-runtime/README.zh.md @@ -85,6 +85,7 @@ MCP 提供方使用 `@deepseek-ai/dsh-experimental-browser-use-runtime/mcp` 中 - **附加范围** — 独占所有权作用于一个资源管理器,不约束独立提供方、进程或外部浏览器客户端。 - **取消** — abort 信号与连接关闭无法撤销已交付的浏览器操作。同时忽略两者的上游操作可能延迟清理。 - **恢复** — 关闭失败会保留所有权;此管理器不重试释放,也不从 Session 日志恢复浏览器状态。 +- **共享宿主运行时** — profile 会把本包与 dsh 安装并排安装,因此 `@deepseek-ai/dsh-scope` 与 `@deepseek-ai/dsh-mcp-client` 保持为 peer 依赖。写成 `dependencies` 会再装一份 `dsh-scope`,它的作用域标记宿主注册表读不到:每个 Agent 的 MCP 工具都会注册到全局工具层,第二个 Agent 的创建随之失败。 ### 开发备注 diff --git a/packages/experimental/browser-use-runtime/package.json b/packages/experimental/browser-use-runtime/package.json index 962b53ad3a..309680dd6c 100644 --- a/packages/experimental/browser-use-runtime/package.json +++ b/packages/experimental/browser-use-runtime/package.json @@ -35,18 +35,20 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-browser-use": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^" }, "dependencies": { - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-browser-use": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/experimental/browser-use-runtime/tests/host-runtime-duplication.spec.ts b/packages/experimental/browser-use-runtime/tests/host-runtime-duplication.spec.ts new file mode 100644 index 0000000000..28298686df --- /dev/null +++ b/packages/experimental/browser-use-runtime/tests/host-runtime-duplication.spec.ts @@ -0,0 +1,128 @@ +/** + * Issue #4573: a profile install of this package sits beside the dsh + * installation and installs its own copy of `@deepseek-ai/dsh-scope`, so two + * scope module instances coexist in one host process. `dsh-scope` mints its + * scope-tag symbol per module instance, so the copy's `createScope` writes a + * tag every host registry ignores: each Agent's MCP tools register in the + * global tool layer, the first Agent succeeds, and every later Agent's tool + * synchronization collides. + * + * The first case reproduces that install layout and pins the failure. The + * second pins the shipped layout the peer declaration produces: one instance, + * two Agents, each with its own browser client. + * + * The first case is a characterization of the duplicated package, not a + * behavior to preserve. Delete it once `dsh-scope` stops depending on module + * identity (for example a `Symbol.for` tag shared across copies). + * + * Run: `pnpm vitest run packages/experimental/browser-use-runtime/tests/host-runtime-duplication.spec.ts` + */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import BrowserUse from '@deepseek-ai/dsh-browser-use' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import Tools from '@deepseek-ai/dsh-tools' +import Llm from '@deepseek-ai/dsh-llm' +import Sessions, { SessionId } from '@deepseek-ai/dsh-session' +import Agents from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import Projections from '@deepseek-ai/dsh-session-projection' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { mountSessionMcp } from '../src/mcp.ts' + +const FIXTURE = fileURLToPath(new URL('./mcp-fixture.mjs', import.meta.url)) +const TOOL = 'mcp__browser-fixture__visit' +const roots: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** Composition with the registries a browser provider contributes to. */ +async function load(): Promise<{ ctx: Context; root: string }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-browser-duplication-')) + roots.push(root) + const modules = new Map([ + ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm], + ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections], + ]) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, JSON.stringify([...modules.keys()].map(name => ({ + id: name, name, config: name === 'loop' ? { agents: [] } : {}, + })))) + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(root).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`Unexpected fixture module ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + return { ctx, root } +} + +/** One MCP client per Agent under a caller-supplied scope context factory. */ +function mountPerAgent( + ctx: Context, + root: string, + scopeFor: (agent: Agent) => Context, + client: typeof import('@deepseek-ai/dsh-mcp-client'), +): void { + ctx.on('agent/created', async ({ agent }) => { + await scopeFor(agent).plugin(client, client.Config({ + transport: 'stdio', serverName: 'browser-fixture', command: process.execPath, args: [FIXTURE, root], + failOnStartupError: true, reconnect: { enabled: false }, + })) + }, { prepend: true }) +} + +const toolNames = (ctx: Context, agent?: Agent): string[] => + ctx.tools.schemas(agent).map(schema => schema.name) + +it('reproduces the second-Agent failure a profile install causes', async () => { + const { ctx, root } = await load() + // What npm installs beside the installation: one second copy of both packages + // in the profile, so the copy's client resolves the copy's scope module. + vi.resetModules() + const profileScope = await import('@deepseek-ai/dsh-scope') + const profileMcpClient = await import('@deepseek-ai/dsh-mcp-client') + mountPerAgent(ctx, root, agent => profileScope.createScope(ctx, agent).ctx, profileMcpClient) + + await ctx.agents.create({ sessionId: SessionId('first'), meta: { cwd: root } }) + expect(toolNames(ctx)).toContain(TOOL) + await expect(ctx.agents.create({ sessionId: SessionId('second'), meta: { cwd: root } })) + .rejects.toThrow('mcp-client(browser-fixture): initial connection or tool synchronization failed') +}) + +it('keeps every Agent browser tool in that Agent scope under the shipped layout', async () => { + const { ctx, root } = await load() + await ctx.plugin({ + inject: ['browserUse', 'agents', 'tools', 'systemPrompt'], + apply(provider: Context) { + mountSessionMcp(provider, { + name: 'browser-fixture', exclusive: false, command: process.execPath, args: [FIXTURE, root], + }) + }, + }) + + const first = await ctx.agents.create({ sessionId: SessionId('first'), meta: { cwd: root } }) + const second = await ctx.agents.create({ sessionId: SessionId('second'), meta: { cwd: root } }) + expect(toolNames(ctx)).toEqual([]) + for (const agent of [first.agent, second.agent]) { + expect(toolNames(ctx, agent)).toContain(TOOL) + } +}) diff --git a/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts b/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts new file mode 100644 index 0000000000..06267f03f3 --- /dev/null +++ b/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts @@ -0,0 +1,36 @@ +/** + * A profile installs this package from npm beside the dsh installation, so the + * host runtimes it mounts must resolve to the installation's single module + * instance. `dsh-scope` mints its scope-tag symbol and `dsh-mcp-client` keeps + * its live `serverName` reservations per module instance. + * + * A dependency edge installs a second copy instead. That copy's `createScope` + * writes a tag every host registry ignores, so each Agent's MCP tools land in + * the global tool layer: the first Agent registers them, and every later + * Agent's tool synchronization fails and rejects creation. + */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +interface Manifest { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record +} + +const manifestPath = fileURLToPath(new URL('../package.json', import.meta.url)) + +/** Host runtimes whose module-local state must not be duplicated. */ +const SHARED_HOST_RUNTIMES = ['@deepseek-ai/dsh-mcp-client', '@deepseek-ai/dsh-scope'] as const + +describe('profile installs share the installation host runtime instances', () => { + it('declares every identity-bearing host runtime as a peer, never a dependency', async () => { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest + for (const name of SHARED_HOST_RUNTIMES) { + expect(manifest.peerDependencies?.[name], `${name} must be a peerDependency`).toBe('workspace:^') + expect(manifest.devDependencies?.[name], `${name} must also be a devDependency`).toBe('workspace:^') + expect(manifest.dependencies?.[name], `${name} must not be a dependency`).toBeUndefined() + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c363e0ee8..fb5d810f1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6432,12 +6432,6 @@ importers: packages/experimental/browser-use-runtime: dependencies: - '@deepseek-ai/dsh-mcp-client': - specifier: workspace:^ - version: link:../../mcp/mcp-client - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -6469,12 +6463,18 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../mcp/mcp-client '@deepseek-ai/dsh-mcp-resources': specifier: workspace:^ version: link:../../mcp/mcp-resources '@deepseek-ai/dsh-ptc-runtime': specifier: workspace:^ version: link:../../ptc-runtime/ptc-runtime + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 135104fb10b0de13c3476d12835d45c24004187f Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 18 Sep 2026 22:20:27 +0800 Subject: [PATCH 02/10] docs(browser-use): record the profile plugin instance-sharing decision Adds the implemented Agent Note for the rule issue #4573 exposed: an out-of-tree plugin declares every host runtime whose cross-instance identity matters as a peer (plus dev), never as a dependency. Records the mechanism (dsh-scope mints its tag per module instance), what the duplicate does to a per-Agent registration, the alternatives that lost (mounting on agent.ctx, a shared Symbol.for tag, version alignment), and the open coverage gap: verify-package-dependencies selects no packages/experimental package, so tests/shared-host-runtimes.spec.ts is the only pin. --- ...le-plugin-host-runtime-instances.i18n.yaml | 6 +++ ...8-profile-plugin-host-runtime-instances.md | 37 +++++++++++++++++++ ...rofile-plugin-host-runtime-instances.zh.md | 37 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md create mode 100644 .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml new file mode 100644 index 0000000000..3545277c9e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md +2026-09-18-profile-plugin-host-runtime-instances.md: f8969b96627ba781fbac3ad45e3d92d62f1d4e78 +2026-09-18-profile-plugin-host-runtime-instances.zh.md: a42b50eff9a651abae7cff6d467ea210a2edc985 diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md new file mode 100644 index 0000000000..f8969b9662 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md @@ -0,0 +1,37 @@ +# Agent Note: Profile plugins share the installation's host runtime instances + +Status: implemented + +English | [中文](2026-09-18-profile-plugin-host-runtime-instances.zh.md) + +## Problem + +A profile resolves a bare specifier against its own `node_modules` before the installation. A plugin the profile installs out-of-tree therefore receives its own copy of every package it reaches through a `dependencies` edge, while the Host that loads the plugin keeps the installation's copy. Most packages tolerate that. `@deepseek-ai/dsh-scope` does not: it tags a scoped context with a module-local `Symbol('dsh.scope')` and keeps scope parents and event-carrier keys in module-local tables, and every registry that reads a tag — tool registration, prompt sections, MCP resources, scoped events — compares it by identity. A profile copy's `createScope` writes a tag the installation's `scopeOf` cannot read, so a registration meant for one Agent lands in the process-global layer. + +Issue #4573 is that failure in production. While this package reached both runtimes through `dependencies`, a profile that mounted the Playwright MCP browser provider installed a second `dsh-scope`: the first Agent's MCP tools registered globally, the second Agent's tool synchronization collided, and `failOnStartupError: true` turned that collision into a rejected Agent creation. Matching the profile's package versions to the Host's did not change the outcome, because a duplicate exists at every version. + +## Decision + +An out-of-tree plugin package declares every host runtime whose cross-instance identity matters in matching `peerDependencies` and `devDependencies`, never in `dependencies`. Profile pnpm settings are `nodeLinker: hoisted` with `autoInstallPeers: false`, so such a peer is not installed into the profile and Node reaches the installation's single instance through the Harness home's module fallback. + +`@deepseek-ai/dsh-scope` carries that identity through `createScope` as well as through the readers `scopeOf`, `carrierKeyOf`, and `scopeTarget`: the writer mints the tag its readers compare. `@deepseek-ai/dsh-mcp-client` joins it because its live `serverName` reservations are module-local, so a second copy keeps a second reservation table for the same namespace. `@deepseek-ai/dsh-experimental-browser-use-runtime` therefore declares both as peers with matching dev entries and keeps `@deepseek-ai/schemastery` as its only runtime dependency, which carries no instance-local state. + +`tests/shared-host-runtimes.spec.ts` pins those sections for that package. `tests/host-runtime-duplication.spec.ts` reproduces what the rule prevents: one second copy of both packages puts each Agent's MCP tools in the global layer and rejects the second Agent's creation, while the shipped single-instance layout keeps the global layer empty and gives both Agents their own client. + +The [published dependency faces](../process/2026-08-26-published-dependency-faces.md) note owns `verify-package-dependencies`: its `peerRequiredHostExports` table classifies the `dsh-scope` readers, and its package selection covers Client-faced packages and an explicit Host list. That gate selects no package under `packages/experimental/`, and it does not classify `createScope`; this note owns the rule for the out-of-tree plugin packages the gate does not reach. + +## Alternatives considered + +**Mount the MCP client on `agent.ctx` instead of a scope this package mints.** It loses because the profile copy's `scopeOf` also cannot read the tag the installation wrote for `agent.ctx`, so the `serverName` reservation degrades to the process-global owner and the second Agent fails at that check — the error form seen on `0.1.6-alpha.1`. Removing the duplicate is the repair for both forms. + +**Make `dsh-scope`'s tag a `Symbol.for`, so two copies agree.** That fixes the tag lookup alone. Scope parents and event-carrier keys stay per copy, so the chain walk and carrier admission of one copy remain blind to links the other recorded — a smaller visible failure with a more confusing cause. It also keeps the duplicate that the profile never needed. + +**Match the profile's package versions to the Host's.** Equal versions still load two module instances from two directories, so the defect survives it. + +## Consequences + +- A released fix reaches a profile when the plugin dependency is upgraded and reinstalled. An already-installed profile keeps its copies until then, and no Host-side change can repair it. +- The rule makes the plugin depend on the installation shipping the peer. Both packages are dependencies of the `@deepseek-ai/dsh` installation, so the fallback resolves them; a plugin outside the installation's closure would fail to load rather than start with a second copy. +- `docs/module-graph.md` renders these edges in its peer section, so the graph states the ownership boundary that the sections encode. +- Three sibling experimental providers (`browser-use-stagehand-native`, `computer-use-cua-driver-mcp`, `computer-use-cua-driver-native`) declare `@deepseek-ai/dsh-mcp-client` as a dependency. They mount no per-Agent MCP client, so this decision leaves them unchanged; giving one of them a per-Agent mount requires moving that declaration to peers first. +- `verify-package-dependencies` still selects no `packages/experimental/` package, so the sections of this package are pinned only by `tests/shared-host-runtimes.spec.ts`. Extending that gate's selection is the open coverage gap this note records. diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md new file mode 100644 index 0000000000..a42b50eff9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Profile 插件共享安装自带的宿主运行时实例 + +Status: implemented + +[English](2026-09-18-profile-plugin-host-runtime-instances.md) | 中文 + +## Problem + +Profile 解析裸模块名时,先看自己的 `node_modules`,再看安装。于是 profile 从 npm 装进来的 out-of-tree 插件,会为它经 `dependencies` 边到达的每个包各留一份自己的副本,而加载它的 Host 用的是安装里那一份。多数包可以容忍这种重复;`@deepseek-ai/dsh-scope` 不能:它用一个模块级 `Symbol('dsh.scope')` 给作用域上下文打标记,并把作用域父链和事件载体键保存在模块级表里,而每个读标记的注册表——工具注册、提示词 section、MCP 资源、作用域事件——都按同一性比较它。profile 那份副本的 `createScope` 写下的标记,安装那份的 `scopeOf` 读不到,于是本该属于某个 Agent 的注册落进了进程全局层。 + +Issue #4573 就是这个问题在生产里的形态。当本包经 `dependencies` 到达这两个运行时时,挂载 Playwright MCP 浏览器提供方的 profile 多装了一份 `dsh-scope`:第一个 Agent 的 MCP 工具注册到全局层,第二个 Agent 的工具同步撞名,`failOnStartupError: true` 把这次撞名放大成 Agent 创建失败。把 profile 的包版本对齐到 Host 并不能改变结果,因为无论哪个版本,副本都是第二份模块实例。 + +## Decision + +Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一律写成匹配的 `peerDependencies` 与 `devDependencies`,绝不写进 `dependencies`。Profile 的 pnpm 配置是 `nodeLinker: hoisted` 加 `autoInstallPeers: false`,这类 peer 不会装进 profile,Node 会经 Harness home 的模块 fallback 找到安装里的那一份实例。 + +`@deepseek-ai/dsh-scope` 的同一性既在读取者 `scopeOf`、`carrierKeyOf`、`scopeTarget` 上,也在写入者 `createScope` 上:标记由写入者铸造,再由读取者比较。`@deepseek-ai/dsh-mcp-client` 同属这一类,因为它在线的 `serverName` 保留表是模块级的,第二份副本会为同一命名空间保留第二张表。因此 `@deepseek-ai/dsh-experimental-browser-use-runtime` 把两者声明为 peer(并补匹配的 dev 条目),只把不携带实例内状态的 `@deepseek-ai/schemastery` 留在运行时依赖里。 + +`tests/shared-host-runtimes.spec.ts` 钉住这个包的分区。`tests/host-runtime-duplication.spec.ts` 复现这条规则拦下的问题:两份包各多一份副本时,每个 Agent 的 MCP 工具都落进全局层,第二个 Agent 的创建被拒;而实际发布的单实例布局下全局层为空,两个 Agent 各拿到自己的客户端。 + +[Published dependency faces](../process/2026-08-26-published-dependency-faces.zh.md) 拥有 `verify-package-dependencies`:它的 `peerRequiredHostExports` 表分类了 `dsh-scope` 的读取者,它的包选择覆盖 Client 面的包与一份显式 Host 名单。该 gate 不选择 `packages/experimental/` 下的任何包,也没有分类 `createScope`;gate 覆盖不到的 out-of-tree 插件包,由本记录拥有这条规则。 + +## Alternatives considered + +**把 MCP 客户端挂到 `agent.ctx`,而不是本包自造的作用域。** 它不成立,是因为 profile 那份副本的 `scopeOf` 同样读不到安装为 `agent.ctx` 写下的标记,`serverName` 保留会退化成进程全局所有者,第二个 Agent 在那一步就失败——即 `0.1.6-alpha.1` 上看到的错误形态。消除重复副本才是两种形态共同的修复。 + +**把 `dsh-scope` 的标记改成 `Symbol.for`,让两份副本互相认得。** 这只修好标记查找。作用域父链与事件载体键仍各留一份,一份副本的链式遍历与载体准入看不见另一份记录的关系——可见错误更小,成因更难定位,而且 profile 本不需要的那份重复依然存在。 + +**把 profile 的包版本对齐到 Host。** 版本相同,两个目录依旧加载出两份模块实例,问题照旧。 + +## Consequences + +- 修复要生效,需要升级并重装 profile 里的插件依赖。在此之前,已装好的 profile 一直带着那份副本,Host 侧无法补救。 +- 这条规则让插件依赖「安装确实带了这个 peer」。两个包都是 `@deepseek-ai/dsh` 安装的依赖,fallback 能解析到;若某插件不在安装闭包内,它会直接加载失败,而不是带着第二份副本启动。 +- `docs/module-graph.md` 把这些边画进 peer 区,图与分区表达的是同一条所有权边界。 +- 另有三个实验提供方(`browser-use-stagehand-native`、`computer-use-cua-driver-mcp`、`computer-use-cua-driver-native`)把 `@deepseek-ai/dsh-mcp-client` 写成依赖。它们不按 Agent 挂 MCP 客户端,本决策不动它们;若将来其中一个改成按 Agent 挂载,必须先把那条声明移到 peer。 +- `verify-package-dependencies` 仍不选择 `packages/experimental/` 下的包,所以本包的分区只由 `tests/shared-host-runtimes.spec.ts` 钉住。扩大该 gate 的选择范围,就是本记录留下的覆盖缺口。 From f8240240fa08f2464e0603d4dfe916a0a93b370e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:55:33 +0800 Subject: [PATCH 03/10] docs(plugins): define localized display metadata and fallback rules --- ...in-management-in-the-web-sidebar.i18n.yaml | 4 +- ...09-plugin-management-in-the-web-sidebar.md | 8 +- ...plugin-management-in-the-web-sidebar.zh.md | 10 +-- ...26-09-10-public-package-manifest.i18n.yaml | 4 +- .../2026-09-10-public-package-manifest.md | 2 +- .../2026-09-10-public-package-manifest.zh.md | 2 +- ...onfiguration-on-the-plugins-page.i18n.yaml | 4 +- ...lugin-configuration-on-the-plugins-page.md | 16 ++-- ...in-configuration-on-the-plugins-page.zh.md | 20 +++-- ...09-18-localized-package-metadata.i18n.yaml | 6 ++ .../2026-09-18-localized-package-metadata.md | 87 +++++++++++++++++++ ...026-09-18-localized-package-metadata.zh.md | 87 +++++++++++++++++++ docs/cookbook/adding-a-package.i18n.yaml | 4 +- docs/cookbook/adding-a-package.md | 55 +++++++++++- docs/cookbook/adding-a-package.zh.md | 53 ++++++++++- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 4 + packages/boot/app-boot/README.zh.md | 4 + packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 + packages/boot/plugin-manager/README.zh.md | 2 + packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 4 + packages/client/locale/README.zh.md | 4 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 6 +- .../client/ui-plugin-manager/README.zh.md | 6 +- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 6 +- .../ui-settings-plugin-inventory/README.zh.md | 6 +- .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 10 ++- packages/host/plugin-inventory/README.zh.md | 10 ++- .../util/package-manifest/README.i18n.yaml | 4 +- packages/util/package-manifest/README.md | 2 + packages/util/package-manifest/README.zh.md | 2 + 36 files changed, 391 insertions(+), 67 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md create mode 100644 .agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml index 07c6da31be..030a4eadb9 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md -2026-09-09-plugin-management-in-the-web-sidebar.md: d2a3329e7c878f25ad5e50d98fc2e3ab39722887 -2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62 +2026-09-09-plugin-management-in-the-web-sidebar.md: ba359aeefe3883445fd5f3e3dc164262e0613219 +2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 91664deeb422c1dc6c34f826a85f398183128d1c diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md index d2a3329e7c..ba359aeefe 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md +++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md @@ -10,9 +10,9 @@ Installed packages belong to the running profile, while Settings is a modal over ## Decision -**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own. +**Management is a sidebar entry.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. It lists installed bundles and installation-provided optional bundles. Settings keeps the read-only plugin inventory, including the installation's own bundles (`dsh-base`, `dsh-web-app`); both inventory groups start collapsed, and the inventory carries no management controls. Configuration placement follows the [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md). -**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings. +**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration forms use the existing global settings bindings. **Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window. @@ -20,11 +20,11 @@ Installed packages belong to the running profile, while Settings is a modal over **A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page. -**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section. +**Configuration placement.** The [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md) owns this choice and its alternatives; sidebar navigation and installation-request ownership remain independent of it. ## Consequences -The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow. +The web bundle's **Plugins** sidebar entry opens profile management. Settings exposes the read-only inventory, while plugin forms live on the Plugins page. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar; `plugin-config` and `settings-chrome` exercise the forms and read-only inventory respectively. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md index aae035b3a9..91664deeb4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md @@ -1,4 +1,4 @@ -# Agent Note:插件管理移到 Web 侧栏 +# Agent Note: 插件管理移到 Web 侧栏 Status: implemented @@ -10,9 +10,9 @@ Status: implemented ## 决定 -**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。 +**管理位于侧栏。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。它列出已安装的组合包与安装随附的可选组合包。设置保留只读插件清单,包括随安装提供的组合包(`dsh-base`、`dsh-web-app`);清单的两个分组默认收起,且不带管理控件。配置的位置遵循[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)。 -**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。 +**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置表单使用原有全局 settings 绑定。 **安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。 @@ -20,11 +20,11 @@ Status: implemented **用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。 -**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。 +**配置的位置。** 该选择及其替代方案由[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)维护;侧栏导航与安装请求的归属独立于它。 ## 后果 -web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。 +web bundle 的**插件**侧栏入口打开 profile 管理。设置提供只读清单,插件表单位于插件页。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器;`plugin-config` 与 `settings-chrome` 分别验证表单和只读清单。 ## 测试 diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml index 8c5140bfb7..de121c7c16 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md -2026-09-10-public-package-manifest.md: 0e6c1650a6b04e98aeba1aa6ef45df78e3f2cb44 -2026-09-10-public-package-manifest.zh.md: 9b7d15f479ca1aab7bea655b2dee43625728a442 +2026-09-10-public-package-manifest.md: 2d35a3ad17c5aa62df4a5eccde351f325936a84f +2026-09-10-public-package-manifest.zh.md: 7dc03e5160b3837ea07e19469b1d8a0a4f64ebe6 diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md index 0e6c1650a6..2d35a3ad17 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md @@ -14,7 +14,7 @@ Plugin authors need npm identity, runtime requirements, and DSH declarations fro Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders. -The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active. +The image packer owns `configTrees`, and the workspace catalog generator owns Session migration declarations. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active. Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy. diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md index 9b7d15f479..7dc03e5160 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md @@ -14,7 +14,7 @@ Status: implemented 运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。 -镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。 +镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。 各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml index d1c3e82cbb..8c89aed087 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md -2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4 -2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc +2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028 +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7 diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md index fb330bee44..475580af48 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -10,21 +10,23 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta ## Decision -**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. +**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens its own page. The [plugin metadata decision](2026-09-18-localized-package-metadata.md) owns that page's title and description; its slot key and technical identities do not change with the displayed text. + +The page passes `view: 'page'` for each form. Official plugin cards and details also use `view: 'summary'`; a row's detail page uses it only when the plugin has no display description. Bundle configuration renders only `page`. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. Sidebar navigation and installation-request ownership follow the [sidebar management decision](2026-09-09-plugin-management-in-the-web-sidebar.md). **Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema. -**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence. +**Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence. **The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired. -**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example. +**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency. ## Consequences - A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own. -- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page. -- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded. +- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page. +- Settings lists the inventory only; the Settings browser goldens cover that read-only section. ## Alternatives considered @@ -32,8 +34,8 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta **A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor. -**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page. +**Keeping the cards in Settings and linking to them from the page.** This separates a plugin's description and configuration across different pages. ## Testing -`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group. +`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page, including description precedence over its summary; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the Official group. diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md index 65c1f5a8bf..22d0aa4ff1 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -1,6 +1,6 @@ -# Agent Note:插件页上的插件配置 +# Agent Note: 插件页上的插件配置 -状态:已实现 +Status: implemented [English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文 @@ -10,21 +10,23 @@ ## 决策 -**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。 +**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开它自己的页面。该页面的标题与描述遵循[插件元信息决策](2026-09-18-localized-package-metadata.zh.md);slot 键和技术身份不随展示文本改变。 + +页面通过 `view: 'page'` 渲染每个表单。官方插件卡片与详情还使用 `view: 'summary'`;行详情页只在插件没有展示描述时使用它。组合包配置仅渲染 `page`。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。侧栏导航与安装请求归属遵循[侧栏管理决策](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)。 **归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。 -**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。 +**只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。 **四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。 -**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。 +**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。 ## 后果 - 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。 -- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。 -- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。 +- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。 +- 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。 ## 考虑过的替代方案 @@ -32,8 +34,8 @@ **从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。 -**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。 +**把卡片留在设置里、从插件页链接过去。** 这会把一个插件的描述和配置分散到不同页面。 ## 测试 -`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。 +`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面,包括描述优先于其摘要的规则;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录官方分组。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml new file mode 100644 index 0000000000..89a7fa2cc4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md +2026-09-18-localized-package-metadata.md: a7e41cccbbce63547e8e5f175ec2350bcf515bc2 +2026-09-18-localized-package-metadata.zh.md: d70aed2a57db4c474245806d4bd3db0129f4beb4 diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md new file mode 100644 index 0000000000..a7e41cccbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md @@ -0,0 +1,87 @@ +# Agent Note: Plugin-owned localized display metadata + +Status: implemented + +English | [中文](2026-09-18-localized-package-metadata.zh.md) + +## Problem + +Plugin Manager needs readable titles and descriptions for installed bundles and individual plugins, including disabled plugins. One npm package can export several plugins with different purposes; a package-level introduction cannot describe each one. Registering introductions only during activation also leaves disabled or failed plugins without display text. + +## Decision + +Each plugin supplies optional `meta.title` and `meta.description` strings in its `locale/.json` resources, starting with `locale/en.json`. These strings directly provide its display title and description. Other top-level locale content belongs to its existing consumers and is not included in metadata responses. + +```json +{ + "meta": { + "title": "File Search", + "description": "Search files in your workspace." + } +} +``` + +The configured Cordis plugin name owns the resource address. Node module resolution selects files through the applicable profile and package exports without evaluating plugin code: + +| Plugin form | Locale resource | +|---|---| +| npm package `my-plugins` | `my-plugins/locale/en.json`, exposed by `./locale/*.json` | +| npm subpath `my-plugins/search` | `my-plugins/search/locale/en.json`, exposed by `./search/locale/*.json` | +| JavaScript path `./plugins/search.js` | No sibling-resource lookup; the configured path is the final title fallback | + +Locale addresses are not derived from a resolved JavaScript filename or an owning package directory. File paths and file URLs, including Windows drive and UNC paths, skip resource resolution and return no metadata; a file address alone is not a metadata error. They supply neither sibling locale metadata nor a neighboring package manifest. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns directory examples, export declarations, publication entries, and author verification. + +Language files for one plugin share a directory. The Host resolves `en.json`, discovers language filenames in that directory, and resolves each resource using the same plugin specifier and parent URL. The English resource anchors discovery, but any language may omit either display field. Language identifiers are case-insensitive and duplicates are rejected. Present locale display fields must be non-empty strings. + +The Host reads installed bundles and their declared plugin rows without importing or activating them. Remotes carry multilingual values or literal fallback text for the two display fields. The Client reuses its existing language selection and fallback chain at render time, without adding language registration, plugin-specific language defaults, or another language fallback policy. + +Each field falls back independently before view-specific name formatting: + +| Field | Existing locale fallback chain | Same-address package fallback | Final fallback | +|---|---|---|---| +| Title | `meta.title` | Non-empty `name` from `/package.json` | Complete configured Cordis plugin name | +| Description | `meta.description` | Non-empty `description` from `/package.json` | No package description | + +The package fallback obeys resource exports and belongs to the same plugin address; a subpath does not inherit its owning package's introduction. The Host supplies the package or final value as the English fallback for a field that has translations but no English value, preserving the existing Client locale API. Missing or unexported resources and missing fields use these fallbacks; invalid locale fields or malformed files report a diagnostic instead of silently falling back, while retaining management operations. + +Plugin Manager uses this metadata on installed bundle cards and details, component lists, and component configuration details, preserving complete technical-name fallbacks. Settings uses it in the plugin inventory, including preset plugins, but shortens literal package-name and module-name fallbacks by removing their npm scope and Cordis/DSH prefixes. Translated titles remain verbatim in both views; full module names, entry ids, search identities, and operation targets stay unchanged. The [Settings inventory README](../../../../packages/client/ui-settings-plugin-inventory/README.md#use-this-package) owns the prefix rules. + +A row configuration page uses its registered `summary` view only when the plugin has no display description. The bundle's raw `description` field remains available to Host and model consumers; it is not an additional UI fallback around resource exports. These rules preserve one displayed description while keeping configuration summaries available for plugins without one. + +Disabled plugins remain readable without activation. The Install view still uses registry information from `pnpm view`; it does not replace that information with locale metadata. Registry, Git, and tarball inspection before installation does not read remote package contents for translation. Model tool results retain their existing package information and exclude multilingual UI dictionaries. Session events remain unchanged. + +Built-in bundle introductions live in their exported language files. Beta markers, page grouping, configuration slot ownership, and enablement targets remain unchanged. Plugin introductions and component interaction copy retain separate owners. + +## Existing decisions + +[Public package declarations](../../implemented/architecture/2026-09-10-public-package-manifest.md) still govern manifest type and reader responsibilities; [locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md) still governs component wording and display identity; [plugin configuration pages](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md) still determine configuration slot ownership. Plugin metadata extends display data without replacing those decisions. + +## Alternatives considered + +**Keep built-in package dictionaries in Plugin Manager.** Every package addition requires changing the manager, and external plugins cannot independently supply translations. + +**Declare references in package.json.** This duplicates the declaration between a manifest and locale files, and one package-level declaration cannot distinguish its exported plugins. + +**Read locale beside the resolved JavaScript file.** Build tools can put several plugin entries in the same directory. Resource exports preserve each plugin's identity independently of its compiled layout. + +**Register copy when the displayed plugin activates.** Disabled packages, packages without a Client half, and packages that fail to load still need readable introductions. + +**Add base.json or package-specific fallback.** This introduces a second policy alongside the existing English fallback and extension-language fallback chains. + +**Download remote packages for pre-installation previews.** This expands lookup cost and the package contents that must be handled; this phase only reads files already present locally. + +## Verification + +- Bundles, child plugins, and read-only inventory display local metadata; disabled packages need no activation for reading. +- Language switching updates titles and descriptions; Settings shortens only literal technical-name fallbacks, not translated titles, and keeps module identities and operation targets intact. +- Direct metadata, unrelated locale content, absent resources, malformed JSON, missing display fields, and conflicting language identifiers have focused coverage. +- Separate exports in one package and same-named packages under different parents retain independent metadata without executing plugin entries. +- Windows drive paths, UNC paths, relative and absolute file paths, and file URLs return no metadata without invoking resource resolution. +- Missing locale resources and individual fields fall back independently to address-local package fields, then the complete module specifier without a description. +- Publication checks require readable resource exports and included language files, without duplicate ownership of built-in copy in the manager and plugins. +- Management tools preserve existing model output, and pre-installation inspection adds no package-content lookup. +- Focused tests and the complete `pnpm run build` succeed. + +## Consequences + +Omitted language files or exports can make metadata unavailable after publication, so verification covers both resolution and packaged files. Metadata errors remain per-plugin diagnostics without removing the manager's repair controls; file diagnostics retain their absolute paths. Display metadata is read per request without a metadata cache. Lookup retains the complete module specifier and its resolution parent; package-name-only caching cannot distinguish exported plugins or different installations. diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md new file mode 100644 index 0000000000..d70aed2a57 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md @@ -0,0 +1,87 @@ +# Agent Note: 插件自有的多语言展示元信息 + +Status: implemented + +[English](2026-09-18-localized-package-metadata.md) | 中文 + +## 问题 + +Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题和描述,包括尚未启用的插件。同一个 npm 包可以导出多个用途不同的插件,包级介绍无法分别描述它们。依赖激活来注册介绍,也会让禁用或加载失败的插件缺少展示文案。 + +## 决策 + +每个插件从 `locale/en.json` 开始,在自己的 `locale/.json` 资源中提供可选的 `meta.title` 与 `meta.description` 字符串。这些字符串直接提供展示标题和描述。locale 的其他顶级内容仍归现有消费方所有,不进入元信息响应。 + +```json +{ + "meta": { + "title": "File Search", + "description": "Search files in your workspace." + } +} +``` + +资源地址归 Cordis 配置中的插件名所有。Node 模块解析通过适用的 profile 和包 exports 选择文件,不执行插件代码: + +| 插件形式 | locale 资源 | +|---|---| +| npm 包 `my-plugins` | 请求 `my-plugins/locale/en.json`,由 `./locale/*.json` 导出 | +| npm 子路径 `my-plugins/search` | 请求 `my-plugins/search/locale/en.json`,由 `./search/locale/*.json` 导出 | +| JavaScript 路径 `./plugins/search.js` | 不查找同级资源;配置中的路径名作为标题的最终回退 | + +locale 地址不从解析后的 JavaScript 文件名或所属包目录推导。文件路径与文件 URL(包括 Windows 盘符路径和 UNC 路径)跳过资源解析,返回无元信息;文件地址本身不构成元信息错误。它们既不提供同级 locale 元信息,也不提供旁边的包 manifest(元数据清单)。目录示例、exports 声明、发布条目与作者验证步骤由[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)维护。 + +同一插件的语言文件放在同一目录。Host 解析 `en.json`,从所在目录发现语言文件名,再用同一插件模块名与父 URL 解析各个资源。英文资源作为发现入口,但任何语言都可以省略任一展示字段。语言标识不区分大小写,重复标识拒绝。locale 中声明的展示字段必须是非空字符串。 + +Host 读取已安装 bundle 及其声明的插件行,不 import 或激活它们。Remote 携带两个展示字段的多语言值或纯文本回退值。Client 在渲染时复用现有 locale 的语言选择与回退链,不增加语言注册、插件级默认语言或另一套语言回退规则。 + +每个字段先独立回退,再按页面规则格式化技术名称: + +| 字段 | 现有 locale 语言回退链 | 同地址包字段回退 | 最终回退 | +|---|---|---|---| +| 标题 | `meta.title` | `<插件模块名>/package.json` 中非空的 `name` | Cordis 配置中的完整插件名 | +| 描述 | `meta.description` | `<插件模块名>/package.json` 中非空的 `description` | 不提供包描述 | + +包字段回退遵守资源 exports,并且仍属于同一插件地址,子路径不继承所属包的介绍。字段有其他语言译文但没有英文值时,Host 将包字段或最终值放入英文回退位,保持现有 Client locale API 不变。资源不存在、未导出或缺少字段时使用上述回退;locale 字段无效或文件格式损坏时明确诊断,不静默回退,同时保留管理操作。 + +插件管理页在已安装 bundle 的卡片和详情、组件列表、组件配置详情中使用这些元信息,保留完整技术名回退。设置中的插件清单也使用它,包括预设内插件,但会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀。两个页面都原样展示翻译标题;完整模块名、条目 id、搜索身份与操作目标保持不变。具体前缀规则由[设置插件清单 README](../../../../packages/client/ui-settings-plugin-inventory/README.zh.md#use-this-package)维护。 + +行配置页仅在插件没有展示描述时使用注册组件的 `summary` 视图。组合包原始的 `description` 字段仍供 Host 与模型消费方使用,不作为绕过资源 exports 的额外 UI 回退。这些规则只展示一份描述,同时保留无描述插件的配置摘要。 + +禁用插件不需要激活即可读取。Install 界面仍使用 `pnpm view` 返回的 registry 信息,不用 locale 元信息替换。安装前的 registry、Git 和 tarball 查询不为翻译读取远程包内容。模型工具结果保留现有包信息,不携带供 UI 使用的多语言字典。Session 事件不变。 + +内置 bundle 的介绍放在其导出的语言文件中。beta 标记、页面分组、配置槽归属与启停目标保持原样。插件介绍和组件自身的交互文案各自保持所有权。 + +## 现有决策 + +[公共包声明](../../implemented/architecture/2026-09-10-public-package-manifest.zh.md) 继续约束 manifest 类型和读取者的职责;[locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md) 继续约束组件文案和展示身份;[插件配置页](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md) 继续决定配置槽归属。插件元信息只扩展展示数据,不取代这些决策。 + +## 考虑过的替代方案 + +**在 Plugin Manager 中继续维护内置包词典。** 每增加一个包都需要修改管理器,而且外部插件无法独立提供翻译。 + +**在 package.json 中声明引用。** 将声明重复放在 manifest 与 locale 文件中,而且一份包级声明无法区分包内导出的插件。 + +**从解析后的 JavaScript 文件旁读取 locale。** 构建工具可能把多个插件入口放在同一目录。资源 exports 让各插件的身份独立于编译产物布局。 + +**由被展示插件激活时注册文案。** 未启用包、无 Client 半边的包和加载失败的包仍然需要可读的介绍。 + +**增加 base.json 或包级 fallback。** 会与现有 locale 的英文兜底和扩展语言回退链产生第二套规则。 + +**为安装前预览下载远程包。** 扩大查询成本及需要处理的包内容;本期只处理已经存在于本地的文件。 + +## 验证 + +- bundle、子插件与只读 inventory 展示本地元信息;禁用包不需要激活即可读取。 +- 切换语言更新标题和描述;设置页仅缩短字面技术名回退,不缩短翻译标题,也不改变模块身份和操作目标。 +- 直接元信息、无关 locale 内容、缺失资源、损坏 JSON、缺失展示字段及语言标识冲突均有针对性覆盖。 +- 同包的不同 exports,以及不同父目录中的同名包,各自保持独立元信息,读取不执行插件入口。 +- Windows 盘符路径、UNC 路径、相对与绝对文件路径及文件 URL 均不调用资源解析,直接返回无元信息。 +- locale 资源或个别字段缺失时,各字段独立回退到该插件地址的包字段,再回退到完整模块名且不显示描述。 +- 发布检查要求资源 exports 可解析且包含语言文件,内置文案不在管理器和插件中双份维护。 +- 管理工具保留现有模型输出,安装前查询不增加包内容探查。 +- 针对性测试以及完整 `pnpm run build` 通过。 + +## 影响 + +遗漏语言文件或 exports 会使发布后的元信息不可用,因此验证覆盖解析与打包后的文件。元信息错误作为单插件诊断呈现,不能让整个管理页失去修复入口;文件诊断保留绝对路径。展示元信息按请求读取,不设元信息缓存。查询保留完整模块名及其解析父地址;只按包名缓存无法区分导出插件或不同安装目录。 diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 6e6c6710dd..b2b0467564 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 771bc2b63dba0234252a6925c0aaa4b4f3a16142 -adding-a-package.zh.md: 8be5e47a206983bef3f86d4210f59159f87a9fae +adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083 +adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 771bc2b63d..8c818bc371 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -14,6 +14,8 @@ packages/// # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) + locale/en.json # optional display metadata: meta.title and meta.description + locale/zh.json # translations using the same fields README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section @@ -69,6 +71,8 @@ Use a singular `ctx` key for one engine, runtime, policy, controller, resolver, Use `SDK` only for the JSON-RPC client/server protocol used by the supported Python and TypeScript SDKs. DeepSeek Harness itself is an agent harness, not an SDK project. Use the canonical product spelling `Typert`, never `TypeRT` or `typeRT`. + + ## 4. Write the package README Keep package-specific service API, config, events, extension points, and design notes first. Choose the frontmatter `kind` from the four kind labels in the [dsh-doc metadata reference](../../.agents/skills/dsh-doc/references/metadata-links-i18n.md#the-kind-system) — group, reference, library, or bundle — matching the package's repository position and entry shape; each kind selects one README template. The limitations section records durable consumer gaps and non-obvious maintainer constraints owned by this package; ordinary cleanup stays in its source TODO or Agent Note. An indirect Model Experience sentence may name the consumer that surfaces this package's contribution, but it does not restate that consumer's implementation. End a package README with this canonical sequence: @@ -105,7 +109,56 @@ Fill Model Experience from the implementation. Use one H3 per direct, conditiona A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts), followed by a `KV Cache effect` H4 and one non-empty paragraph; a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale. -## 5. Verify + + +## 5. Add optional plugin display metadata + +For an npm package plugin, define its title and description in `locale/en.json`. Other language files, such as `locale/zh.json`, use the same fields: + +```json +{ + "meta": { + "title": "Workspace Tools", + "description": "Tools for your workspace." + } +} +``` + +Merge these entries into `package.json`, retaining existing runtime exports and publication files: + +```json +{ + "exports": { + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": ["locale/*.json"] +} +``` + +Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. + +Fields fall back independently before view-specific name formatting, using the existing locale language chain first: + +- Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name. +- Description: locale `meta.description` → `package.json.description` → no description. + +Export `/locale/en.json` for locale lookup; expose `/package.json` when package-field fallback is needed. + +Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. + +Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description. + +The Install view still uses npm registry information from `pnpm view`, not locale metadata. + +Verify the result: + +1. Run `pnpm run verify-package-meta` from the repository root to check fields, resource exports, and publication coverage. +2. Switch an installed plugin's applicable Plugin Manager and Settings entries between English and Chinese; check the title, description, per-field fallback, and Settings-only compact technical names. + +See the [plugin metadata Agent Note](../../.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md) for resource ownership and non-activation rationale. + +## 6. Verify ```sh pnpm install # registers the workspace diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 8be5e47a20..f6a09b411e 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -14,6 +14,8 @@ packages/// # ../../../vendor/cordis (+ ../../../vendor/schemastery if # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) + locale/en.json # optional display metadata: meta.title and meta.description + locale/zh.json # translations using the same fields README.md # service API, events, extension points, design notes, # + gated Model Experience context blocks or short form # + the gated "Known Limitations and Deferred Work" section @@ -107,7 +109,56 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex 没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md) 记录了设计动机。 -## 5. 验证 + + +## 5. 添加可选的插件展示元信息 + +对于 npm 包插件,在 `locale/en.json` 中定义标题和描述。其他语言文件(如 `locale/zh.json`)使用相同字段: + +```json +{ + "meta": { + "title": "Workspace Tools", + "description": "Tools for your workspace." + } +} +``` + +将以下条目合并进 `package.json`,保留已有的运行时 exports 和发布文件: + +```json +{ + "exports": { + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": ["locale/*.json"] +} +``` + +语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 + +各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链: + +- 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。 +- 描述:locale `meta.description` → `package.json.description` → 不显示描述。 + +导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。 + +已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 + +仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。 + +Install 界面仍使用 `pnpm view` 返回的 npm registry 信息,不用 locale 元信息替换。 + +验证结果: + +1. 在仓库根目录运行 `pnpm run verify-package-meta`,检查字段、资源 exports 和发布文件覆盖。 +2. 对已安装插件,在适用的插件管理页与设置条目中切换中英文,检查标题、描述、逐字段回退,以及仅设置页使用的技术短名。 + +资源归属与不激活插件的原因见[插件元信息 Agent Note](../../.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md)。 + +## 6. 验证 ```sh pnpm install # registers the workspace diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 8a41d074ff..aed223888a 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: fed6f6833dd22254270b86beccd797d3b6054d7f -README.zh.md: bedf8ae3d019607cbf04e24a38febd3441e06bbd +README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594 +README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index fed6f6833d..8439fb9ec1 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -66,6 +66,10 @@ Before mounting profile rows, the `dsh` launcher computes one immutable runtime Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump. +### Reading plugin display metadata + +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. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. + ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index bedf8ae3d0..0bb8c9819d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -66,6 +66,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。 +### 读取插件展示元信息 + +使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 + ### 启动与重载失败 diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index c0468fb558..a55cf74eee 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: d856a5db5d2dd05a375cbeb863758b66600fcd6e -README.zh.md: 1ab2d4de95f9af27d113faa844cb3bf42b06418e +README.md: d937d265a7942f824f5f4a02c0a5eaba62208994 +README.zh.md: 388a2e249613299753ae63c3c20d2ca89311dc4f diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index d856a5db5d..d937d265a7 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,6 +41,8 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. The `plugin_manager` tool omits UI display metadata from list results. + `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. 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 1ab2d4de95..388a2e2496 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,6 +41,8 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 + `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 注册表按顺序询问。计划从 `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/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 14b3339b26..4865a1b040 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284 -README.zh.md: 1b7a7b7de48843961f3cbac3f4ee99cff641d212 +README.md: 3d3771bf1cf61adf7048b5660cab6394af9cd9a0 +README.zh.md: d427c388de1b3b1e82635fbe39d3bb43b7515488 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index 56a9cff9c3..3d3771bf1c 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -35,6 +35,10 @@ Open Settings → General and select a registered language. The active locale is Call `ctx.locale.register(ns, { zh, en })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both shipped locales. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount. +### Resolving package text + +Use `ctx.locale.resolveText(text)` for [`LocalizedText`](../../util/package-manifest/README.md), such as installed plugin titles and descriptions. Literal strings are returned unchanged. Translation maps use lowercase language ids, require an `en` fallback, and follow the active language's declared fallback chain. They do not consult or register namespace dictionaries. + ### Registering a language pack An external client plugin registers the language definition and each translated namespace as owned effects; definitions and dictionaries may register in either order: diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 1b7a7b7de4..d427c388de 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -35,6 +35,10 @@ kind: "package-reference" 用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个内置 locale 齐全。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。 +### 解析包文本 + +使用 `ctx.locale.resolveText(text)` 解析 [`LocalizedText`](../../util/package-manifest/README.zh.md),例如已安装插件的标题与描述。字面字符串原样返回。翻译映射使用小写语言 id,必须提供 `en` 回退值,并沿当前语言声明的回退链查找。它们不查询或注册命名空间字典。 + ### 注册语言包 外部 client 插件把语言定义和每个已翻译命名空间注册为自身拥有的 effect;定义与字典可以按任意顺序注册: diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index b5d3e468b8..dfbe7fcedb 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: da6571598bbb2ef613b0f5d885339bb167e3df8d -README.zh.md: 7c5bcaa12650a361b4b0e933ac40ea58a9222c42 +README.md: d7bcd2299d93dab90c8440565ac87112b37c9c10 +README.zh.md: 368058dabc27968b86088c34ee387dc5dd5bd364 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index da6571598b..d7bcd2299d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — 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 **Plugins** section. -The Agent Teams and Auto Authorization Review packages have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description. +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` at that plugin address; 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. Installation previews still use registry or manifest information. ### Installing a bundle @@ -41,11 +41,11 @@ A bundle's page shows its full package name under the title, the spec that insta ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names. ### Configuration pages -A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: +A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page renders `view: 'page'` for forms with their own save controls. Official plugin cards also render `view: 'summary'` under the title; a row's detail page uses that view only when its package description is absent. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way: ```tsx ignore-check ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 7c5bcaa126..368058dabc 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 -Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。 +已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。 ### 安装一个组合包 @@ -41,11 +41,11 @@ Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。 ### 配置页 -自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: +自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面用 `view: 'page'` 渲染带自己保存控件的表单。官方插件卡片还在标题下渲染 `view: 'summary'`;行详情页只在缺少包描述时使用该视图。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册: ```tsx ignore-check ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index ecf06c92ec..a55dda8361 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md -README.md: 13703878476f88eede9d257094e88e7106499e15 -README.zh.md: 6c77b8029b4ef35f6f2028d8ef0d37219d5c1798 +README.md: 2c456dfef05444a04f6282ca2c1bb4a2ba8bb0ce +README.zh.md: b69803627371dee9f543dceed1282fcf17a7b48c diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index 1370387847..2c456dfef0 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards retain the package name as the primary title, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. +The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster. ## Table of Contents @@ -31,7 +31,9 @@ A failed installation with pending pnpm build permissions offers **Allow these s ### Reading a card -Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id. +Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page. + +The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by localized title and description, module name, and entry id. ### The preset switcher diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 6c77b8029b..b698036273 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片保留包名作为主标题,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 +**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。 ## 目录 @@ -31,7 +31,9 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。 +每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。 + +稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 ### 预设切换器 diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 2cb23a1f0d..0f4145b598 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md -README.md: b64a017df28716cfd0b5c3a41f437b87b8e4f19e -README.zh.md: a29bc402537d88fa512675bfb768502753ca7929 +README.md: c882b6e00d16ec5b991ec0b6b5a45f7122b04609 +README.zh.md: 902952741332319a18b1cca7d683340af6f6004c diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index b64a017df2..c882b6e00d 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Clients can call `pluginInventory/list` to display the host’s current plugins in load order, including each entry’s identifier, module specifier, effective enablement, and live phase. Deployments with an agent-preset roster also report each preset’s metadata, health, and flattened plugin composition; without a roster, preset data is absent. Each response is a point-in-time, read-only snapshot for display and diagnostics: it cannot mutate plugins and provides no history, introduction source, or change subscription. +Clients can call `pluginInventory/list` to display the host’s current plugins in load order, including each entry’s identifier, module specifier, effective enablement, live phase, and available display text. Deployments with an agent-preset roster also report each preset’s metadata, health, and flattened plugin composition; without a roster, preset data is absent. Each response is a point-in-time, read-only snapshot for display and diagnostics: it cannot mutate plugins and provides no history or change subscription. ## Table of Contents @@ -31,6 +31,8 @@ Call `pluginInventory/list` when a client or settings page needs to show what is Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped. +Loader and preset rows can carry optional `meta` with a title, description, or metadata diagnostic. The Host returns available translations and literal fallbacks; the Client selects its language. Metadata diagnostics do not change enablement or fiber phase. + ### Per-preset compositions With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent. @@ -49,7 +51,9 @@ The inventory is a snapshot for display and diagnostics: a client can render the ### Design concept -The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal `plugin/status` events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. The agent-preset roster is an optional peer resolved per call through `ctx.get('agentPresets')`: its `compositionInventory()` owns every preset read, and this package only maps root-fiber states onto the public phase vocabulary. +The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal `plugin/status` events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. The agent-preset roster is an optional peer resolved per call through `ctx.get('agentPresets')`: its `compositionInventory()` owns preset composition reads, and this package maps root-fiber states onto the public phases. + +Display metadata comes from the optional `pluginPackages` service using each Loader tree's resolution base, or the gateway context's base for preset rows. Without the service or the applicable base, `meta` is absent. Reading metadata does not load or activate plugins. ### The phase mapping @@ -97,7 +101,7 @@ None; this package neither assembles nor sends a provider request. These limits define what a point-in-time inventory cannot tell a client. They are current package constraints, not a task backlog. - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. -- **No introduction source or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane. +- **No layer attribution or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane. - **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index a29bc40253..9029527413 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -客户端可以调用 `pluginInventory/list`,按加载顺序展示宿主的当前插件,包括每个条目的标识符、模块标识、有效启用状态与存活阶段。部署组合了 agent preset roster 时,还会报告各预设的元数据、健康状态与压平后的插件组合;没有 roster 时,预设数据缺席。每次响应都是供展示和诊断使用的只读即时快照:它不能修改插件,也不提供历史、来源信息或变更订阅。 +客户端可以调用 `pluginInventory/list`,按加载顺序展示宿主的当前插件,包括每个条目的标识符、模块标识、有效启用状态、存活阶段与可用的展示文本。部署组合了 agent preset roster 时,还会报告各预设的元数据、健康状态与压平后的插件组合;没有 roster 时,预设数据缺席。每次响应都是供展示和诊断使用的只读即时快照:它不能修改插件,也不提供历史或变更订阅。 ## 目录 @@ -31,6 +31,8 @@ kind: "package-reference" 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。 +Loader 行与预设行可以携带可选的 `meta`,其中包含标题、描述或元信息诊断。Host 返回可用翻译与字面回退文本,由 Client 选择语言。元信息诊断不改变启停状态或 fiber 阶段。 + ### 每个预设的组合 组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新仍存续的世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。 @@ -49,7 +51,9 @@ kind: "package-reference" ### 设计理念 -网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 `plugin/status` 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。agent preset roster 是每次调用经 `ctx.get('agentPresets')` 解析的可选伙伴:所有预设读取都由它的 `compositionInventory()` 负责,本包只把根 Fiber 状态映射到公共阶段词汇。 +网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 `plugin/status` 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。agent preset roster 是每次调用经 `ctx.get('agentPresets')` 解析的可选伙伴:预设组合读取由它的 `compositionInventory()` 负责,本包把根 Fiber 状态映射到公共阶段。 + +展示元信息来自可选的 `pluginPackages` 服务:Loader 行使用其所属树的解析基准,预设行使用网关上下文的基准。服务或对应基准不存在时,`meta` 缺席。读取元信息不会加载或激活插件。 ### 阶段映射 @@ -97,7 +101,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产 这些限制说明即时清单无法向客户端提供哪些信息。它们是当前包约束,不是任务积压。 - **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 -- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。 +- **不标识引入层,也不修改插件**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。 - **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。 diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 0a57d9a493..3e100a15b0 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/package-manifest/README.md -README.md: 36e25a7bee7ce6cc31133e0c815c0dc607c58de7 -README.zh.md: d46ae346c2a654a0bfbfb4e7e7b2ba317f85a762 +README.md: 1edb4f802a3ceb4981f3c66e59a66d50edce22b5 +README.zh.md: 81af9eeb1a6504c5b19aa73509f69697c1726c6c diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 36e25a7bee..1edb4f802a 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -52,6 +52,8 @@ The following metadata fields are optional. Omitting them leaves the format vers | `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | | `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. | +`LocalizedText` carries literal text or a language map with a required English fallback. `PackageMeta` carries optional display title and description fields, or a metadata diagnostic, for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. + Public composition declarations are defined in [`src/types.ts`](src/types.ts). Internal `configTrees`, `sessionFormatMigration`, and generated `moduleFallback` metadata remain owned by their image-packer, catalog, and launcher readers; the public types do not expose them. ----- diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index d46ae346c2..81af9eeb1a 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -52,6 +52,8 @@ const manifest: DshPackageManifest = { | `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | | `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 | +`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PackageMeta` 携带已安装插件的可选展示标题与描述字段,或元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 + 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 ----- From 0b7cb4102b096031a8e4f8312857884e31fd6030 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:55:43 +0800 Subject: [PATCH 04/10] feat(plugins): resolve installed metadata through runtime resource exports --- packages/boot/app-boot/src/index.ts | 1 + packages/boot/app-boot/src/package-meta.ts | 137 ++++++++ .../src/profile-resolution/service.ts | 12 + .../boot/app-boot/tests/package-meta.spec.ts | 327 ++++++++++++++++++ .../tests/profile-resolution-service.spec.ts | 29 +- packages/boot/plugin-manager/package.json | 1 + packages/boot/plugin-manager/src/index.ts | 26 +- packages/boot/plugin-manager/src/tools.ts | 5 +- packages/boot/plugin-manager/src/types.ts | 7 +- .../boot/plugin-manager/tests/manager.spec.ts | 101 +++++- .../boot/plugin-manager/tests/tools.spec.ts | 15 + packages/boot/plugin-manager/tsconfig.json | 3 + packages/host/plugin-inventory/package.json | 2 + packages/host/plugin-inventory/src/index.ts | 23 +- packages/host/plugin-inventory/src/types.ts | 5 + .../plugin-inventory/tests/inventory.spec.ts | 110 +++++- packages/host/plugin-inventory/tsconfig.json | 6 + packages/util/package-manifest/src/index.ts | 4 +- packages/util/package-manifest/src/types.ts | 15 +- pnpm-lock.yaml | 9 + 20 files changed, 812 insertions(+), 26 deletions(-) create mode 100644 packages/boot/app-boot/src/package-meta.ts create mode 100644 packages/boot/app-boot/tests/package-meta.spec.ts diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index bb7d055649..a20d1e50ce 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -19,6 +19,7 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts' export { sanitizeProfile } from './profile-sanitize.ts' +export { readPluginMeta } from './package-meta.ts' import type {} from '@deepseek-ai/dsh-system-prompt' export { diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts new file mode 100644 index 0000000000..30e6071f5b --- /dev/null +++ b/packages/boot/app-boot/src/package-meta.ts @@ -0,0 +1,137 @@ +/** Read plugin display metadata through exported locale resources without evaluating plugin code. */ + +import { readdirSync, readFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' +import type { LocalizedText, PackageMeta } from '@deepseek-ai/dsh-package-manifest' +import { barePackageName } from './profile-resolution/resolver.ts' + +const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u + +type DisplayFields = Record<'title' | 'description', string | undefined> + +/** + * Resolve a plugin resource through the active Node ESM resolver without evaluating it. + * @param specifier - complete resource module specifier, including its locale filename. + * @param parentURL - owning module-resolution base. + * @returns the local filesystem path selected by Node and the active profile. + * @throws when the resolver is unavailable or the resource cannot resolve to a local file. + */ +export function resolvePluginResource(specifier: string, parentURL: string): string { + const loader = ModuleLoader.fromInternal() + if (loader === undefined) throw new Error('Plugin metadata requires the Node module resolver') + const result = loader.version === 'v2' + ? loader.resolveSync(parentURL, { specifier, attributes: {} }) + : loader.resolveSync(specifier, parentURL, {}) + return fileURLToPath(result.url) +} + +function missingResource(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || code === 'ERR_MODULE_NOT_FOUND' + || code === 'MODULE_NOT_FOUND' || code === 'ENOENT' || code === 'ENOTDIR' +} + +function optionalResourcePath(specifier: string, parentURL: string): string | undefined { + try { + return resolvePluginResource(specifier, parentURL) + } catch (error) { + if (missingResource(error)) return undefined + throw error + } +} + +function objectOf(value: unknown, path: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`) + } + return value as Record +} + +function textOf(value: unknown, path: string): string | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${path} must be a non-empty string`) + return value +} + +function readObject(file: string): Record { + let contents: unknown + try { + contents = JSON.parse(readFileSync(file, 'utf8')) as unknown + } catch (error) { + throw new Error(`${file}: ${String(error)}`) + } + return objectOf(contents, file) +} + +function fallbackText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value : undefined +} + +function dictionariesOf(englishPath: string, specifier: string, parentURL: string): Map { + const dictionaries = new Map() + for (const entry of readdirSync(dirname(englishPath), { withFileTypes: true })) { + if (!entry.name.endsWith('.json')) continue + const resource = `${specifier}/locale/${entry.name}` + const language = entry.name.slice(0, -5) + if (!LANGUAGE_ID.test(language)) throw new Error(`${resource} must use a language id as its filename`) + const id = language.toLowerCase() + if (dictionaries.has(id)) throw new Error(`${resource} duplicates locale ${id}`) + const file = resolvePluginResource(resource, parentURL) + if (dirname(file) !== dirname(englishPath)) { + throw new Error(`${resource}: ${file} must share the English locale directory ${dirname(englishPath)}`) + } + const parsed = readObject(file) + const meta = parsed.meta === undefined ? undefined : objectOf(parsed.meta, `${file}: meta`) + dictionaries.set(id, { + title: textOf(meta?.title, `${file}: meta.title`), + description: textOf(meta?.description, `${file}: meta.description`), + }) + } + return dictionaries +} + +function localizedText( + field: keyof DisplayFields, dictionaries: Map, fallback: string | undefined, finalFallback: string, +): LocalizedText | undefined { + const entries = [...dictionaries].flatMap(([language, fields]): [string, string][] => { + const value = fields[field] + return value === undefined ? [] : [[language, value]] + }) + if (entries.length === 0) return fallback + return { en: fallback ?? finalFallback, ...Object.fromEntries(entries) } +} + +/** + * Read meta.title and meta.description from a plugin's exported locale JSON files. + * Non-package specifiers are skipped without invoking the resource resolver. + * Language files share the directory containing the resolved English resource; + * each file is resolved through the complete plugin specifier before reading. + * Missing fields use the same address's package.json name/description. Translation + * maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions. + * @param specifier - configured plugin module name, including any package subpath. + * @param parentURL - owning Loader tree's module-resolution base. + * @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text. + */ +export function readPluginMeta(specifier: string, parentURL: string): PackageMeta | undefined { + if (barePackageName(specifier) === undefined) return undefined + try { + const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) + const dictionaries = englishPath === undefined ? new Map() : dictionariesOf(englishPath, specifier, parentURL) + const english = dictionaries.get('en') + const manifestPath = english?.title === undefined || english.description === undefined + ? optionalResourcePath(`${specifier}/package.json`, parentURL) + : undefined + const manifest = manifestPath === undefined ? undefined : readObject(manifestPath) + const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier) + const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '') + if (title === undefined && description === undefined) return undefined + return { + ...title === undefined ? {} : { title }, + ...description === undefined ? {} : { description }, + } + } catch (error) { + return { error: `Plugin metadata for ${specifier}: ${String(error)}` } + } +} diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts index f0b92228dd..d5b3e1bec8 100644 --- a/packages/boot/app-boot/src/profile-resolution/service.ts +++ b/packages/boot/app-boot/src/profile-resolution/service.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import { Service, type Context } from '@deepseek-ai/cordis' +import type { PackageMeta } from '@deepseek-ai/dsh-package-manifest' import { barePackageName, installRuntimeInterception, @@ -11,6 +12,7 @@ import { type RuntimeInterception, } from './resolver.ts' import type { RuntimeResolution } from '../profile.ts' +import { readPluginMeta } from '../package-meta.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -101,6 +103,16 @@ export class PluginPackages extends Service { if (!this.packages.has(key)) this.packages.set(key, readPackage(dir, name)) return this.packages.get(key) } + + /** + * Read display metadata without loading or activating the target plugin. + * @param specifier - configured package module, including package subpaths. + * @param parentURL - owning Loader tree's resolution base. + * @returns local display metadata or its diagnostic; undefined for non-package requests or absent metadata. + */ + metaOf(specifier: string, parentURL: string): PackageMeta | undefined { + return readPluginMeta(specifier, parentURL) + } } function packageDirFromParent(name: string, parentURL: string): string | undefined { diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts new file mode 100644 index 0000000000..07b9af374b --- /dev/null +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -0,0 +1,327 @@ +/** Plugin locale resources resolve independently of entry execution and package display fields. */ + +import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readPluginMeta, resolvePluginResource } from '../src/package-meta.ts' + +let root: string +let dir: string +let parentURL: string + +function file(path: string, text: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, text) +} + +function manifest(exports: object, extra: object = {}): void { + file(join(dir, 'package.json'), JSON.stringify({ name: 'localized', type: 'module', exports, ...extra })) +} + +function dictionary(language: string, contents: unknown, directory = join(dir, 'locale')): void { + file(join(directory, `${language}.json`), JSON.stringify(contents)) +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-plugin-meta-')) + dir = join(root, 'node_modules', 'localized') + parentURL = pathToFileURL(join(root, 'entry.mjs')).href + manifest({ '.': './index.js', './locale/*.json': './locale/*.json' }, { description: 'Not local display text.' }) + file(join(dir, 'index.js'), 'throw new Error("metadata must not execute the plugin")\n') +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +describe('plugin locale display metadata', () => { + it.each([ + 'C:\\plugins\\search.js', + 'C:/plugins/search.js', + '\\\\server\\share\\plugins\\search.js', + '//server/share/plugins/search.js', + '.\\plugins\\search.js', + './plugins/search.js', + '../plugins/search.js', + '/plugins/search.js', + 'file:///plugins/search.js', + 'file:///C:/plugins/search.js', + 'file://server/share/plugins/search.js', + ])('does not resolve metadata resources for plugin file address %s', (specifier) => { + const original = ModuleLoader.fromInternal + const resolver = vi.spyOn(ModuleLoader, 'fromInternal') + try { + expect(readPluginMeta(specifier, parentURL)).toBeUndefined() + expect(resolver).not.toHaveBeenCalled() + } finally { + resolver.mockRestore() + } + expect(ModuleLoader.fromInternal).toBe(original) + }) + + it('omits absent resources and absent meta fields without reading npm descriptions', () => { + expect(readPluginMeta('localized', parentURL)).toBeUndefined() + expect(readPluginMeta('absent', parentURL)).toBeUndefined() + expect(readPluginMeta('node:fs', parentURL)).toBeUndefined() + expect(readPluginMeta('fs', parentURL)).toBeUndefined() + expect(readPluginMeta('cordis:group', parentURL)).toBeUndefined() + dictionary('en', { other: { title: 'Not metadata' } }) + expect(readPluginMeta('localized', parentURL)).toBeUndefined() + dictionary('en', { meta: {} }) + expect(readPluginMeta('localized', parentURL)).toBeUndefined() + }) + + it('reads direct fields, retains per-field translations, and ignores other locale content', () => { + dictionary('en', { meta: { title: 'Team', description: 'Work together', ignored: false }, other: { nested: [1] } }) + dictionary('zh', { meta: { title: '团队' } }) + dictionary('pt-BR', { meta: { description: 'Trabalhar juntos' } }) + file(join(dir, 'locale', 'ignored.txt'), 'not JSON') + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'Team', zh: '团队' }, + description: { en: 'Work together', 'pt-br': 'Trabalhar juntos' }, + }) + }) + + it('treats percent markers as literal display text', () => { + dictionary('en', { meta: { title: '%title%', description: 'Plugin %name%' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: '%title%' }, description: { en: 'Plugin %name%' }, + }) + }) + + it('accepts description-only metadata and supplies the module name when a translated title has no English value', () => { + dictionary('en', { meta: { description: 'About it' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ description: { en: 'About it' } }) + dictionary('zh', { meta: { title: '标题' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'localized', zh: '标题' }, description: { en: 'About it' }, + }) + }) + + it('falls back to package fields when locale resources are absent', () => { + manifest({ '.': './index.js', './package.json': './package.json' }, { description: 'Package introduction' }) + expect(readPluginMeta('localized', parentURL)).toEqual({ title: 'localized', description: 'Package introduction' }) + }) + + it('falls back per field without replacing available locale translations', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { description: 'Package introduction' }) + dictionary('en', { meta: { title: 'English title' } }) + dictionary('zh', { meta: { description: '中文介绍' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'English title' }, description: { en: 'Package introduction', zh: '中文介绍' }, + }) + dictionary('en', { meta: { description: 'English introduction' } }) + dictionary('zh', { meta: { title: '中文标题' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'localized', zh: '中文标题' }, description: { en: 'English introduction' }, + }) + dictionary('en', {}) + dictionary('zh', {}) + expect(readPluginMeta('localized', parentURL)).toEqual({ title: 'localized', description: 'Package introduction' }) + }) + + it('retains non-English descriptions with an empty final English fallback', () => { + dictionary('en', { meta: { title: 'Plugin' } }) + dictionary('zh', { meta: { description: '中文介绍' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'Plugin' }, description: { en: '', zh: '中文介绍' }, + }) + }) + + it('uses only the package resource exported for the requested plugin address', () => { + manifest({ + './package.json': './package.json', + './search/package.json': './search-manifest.json', + }, { description: 'Whole package' }) + file(join(dir, 'search-manifest.json'), JSON.stringify({ name: 'Search plugin', description: 'Search introduction' })) + expect(readPluginMeta('localized/search', parentURL)).toEqual({ title: 'Search plugin', description: 'Search introduction' }) + expect(readPluginMeta('localized/review', parentURL)).toBeUndefined() + }) + + it.each([{}, { name: '', description: ' ' }, { name: false, description: null }])('ignores unavailable package text: %j', (fields) => { + manifest({ './feature/package.json': './feature.json' }) + file(join(dir, 'feature.json'), JSON.stringify(fields)) + expect(readPluginMeta('localized/feature', parentURL)).toBeUndefined() + }) + + it('reports malformed fallback files', () => { + manifest({ './feature/package.json': './feature.json' }) + file(join(dir, 'feature.json'), '{') + expect(readPluginMeta('localized/feature', parentURL)?.error).toContain('feature.json') + }) + + it('does not hide invalid locale fields behind package text', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { description: 'Package description' }) + dictionary('en', { meta: { title: false } }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string') + }) + + it('does not inspect package fallback when both English fields are supplied', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './fallback.json' }) + file(join(dir, 'fallback.json'), '{') + dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } }) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'Plugin' }, description: { en: 'Locale introduction' }, + }) + }) + + it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => { + manifest({ + './search': './lib/search.js', './review': './lib/review.js', + './search/locale/*.json': './resources/search/*.json', + './review/locale/*.json': './resources/review/*.json', + './locale/*.json': './locale/*.json', + }) + file(join(dir, 'lib', 'search.js'), 'throw new Error("must not execute search")\n') + file(join(dir, 'lib', 'review.js'), 'throw new Error("must not execute review")\n') + dictionary('en', { meta: { title: 'Whole package' } }) + dictionary('en', { meta: { title: 'Search' } }, join(dir, 'resources', 'search')) + dictionary('zh', { meta: { title: '搜索' } }, join(dir, 'resources', 'search')) + dictionary('en', { meta: { title: 'Review' } }, join(dir, 'resources', 'review')) + expect(readPluginMeta('localized/search', parentURL)).toEqual({ title: { en: 'Search', zh: '搜索' } }) + expect(readPluginMeta('localized/review', parentURL)).toEqual({ title: { en: 'Review' } }) + expect(readPluginMeta('localized/private', parentURL)).toBeUndefined() + }) + + it('reads scoped package roots and subexports at their complete addresses', () => { + const scoped = join(root, 'node_modules', '@scope', 'localized') + file(join(scoped, 'package.json'), JSON.stringify({ + name: '@scope/localized', + exports: { './locale/*.json': './locale/*.json', './search/locale/*.json': './search-locale/*.json' }, + })) + dictionary('en', { meta: { title: 'Scoped package' } }, join(scoped, 'locale')) + dictionary('en', { meta: { title: 'Scoped search' } }, join(scoped, 'search-locale')) + expect(readPluginMeta('@scope/localized', parentURL)).toEqual({ title: { en: 'Scoped package' } }) + expect(readPluginMeta('@scope/localized/search', parentURL)).toEqual({ title: { en: 'Scoped search' } }) + }) + + it('uses ESM import conditions for locale exports without loading JSON modules', () => { + manifest({ './locale/*.json': { import: './esm/*.json', require: './cjs/*.json' } }) + dictionary('en', { meta: { title: 'ESM' } }, join(dir, 'esm')) + dictionary('en', { meta: { title: 'CJS' } }, join(dir, 'cjs')) + expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'ESM' } }) + }) + + it('does not read unexported language files directly from the resolved English directory', () => { + manifest({ './locale/en.json': './locale/en.json' }) + dictionary('en', { meta: { title: 'English' } }) + dictionary('zh', { meta: { title: '中文' } }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('./locale/zh.json') + }) + + it('reports language resources mapped outside their English directory', () => { + manifest({ './locale/*.json': './locale/*.json', './locale/zh.json': './separate/zh.json' }) + dictionary('en', { meta: { title: 'English' } }) + dictionary('zh', { meta: { title: 'Local Chinese' } }) + dictionary('zh', { meta: { title: 'Mapped Chinese' } }, join(dir, 'separate')) + expect(readPluginMeta('localized', parentURL)?.error).toContain('must share the English locale directory') + }) + + it('resolves same-named packages independently under each importing parent', () => { + dictionary('en', { meta: { title: 'First' } }) + const second = join(root, 'second', 'node_modules', 'localized') + file(join(second, 'package.json'), JSON.stringify({ name: 'localized', exports: { './locale/*.json': './locale/*.json' } })) + dictionary('en', { meta: { title: 'Second' } }, join(second, 'locale')) + const secondParent = pathToFileURL(join(root, 'second', 'entry.mjs')).href + expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'First' } }) + expect(readPluginMeta('localized', secondParent)).toEqual({ title: { en: 'Second' } }) + }) + + it.each([null, [], false, 1, 'text'])('reports malformed meta: %j', (meta) => { + dictionary('en', { meta }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('meta must be an object') + }) + + it.each([null, false, 1, {}, '', ' '])('reports a malformed display field: %j', (title) => { + dictionary('en', { meta: { title } }) + const localeFile = resolvePluginResource('localized/locale/en.json', parentURL) + expect(readPluginMeta('localized', parentURL)?.error).toContain(localeFile + ': meta.title must be a non-empty string') + }) + + it.each([[], null, 'text'])('rejects a non-object locale document: %j', (contents) => { + dictionary('en', contents) + expect(readPluginMeta('localized', parentURL)?.error).toContain('en.json must be an object') + }) + + it('reports malformed JSON and invalid language filenames', () => { + dictionary('en', { meta: { title: 'Title' } }) + file(join(dir, 'locale', 'zh.json'), '{') + expect(readPluginMeta('localized', parentURL)?.error).toContain('zh.json') + rmSync(join(dir, 'locale', 'zh.json')) + dictionary('not_a_language', { meta: { title: 'Title' } }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('not_a_language.json must use a language id') + }) + + it('reports invalid export targets instead of treating them as absent metadata', () => { + manifest({ './locale/*.json': '../outside/*.json' }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('Invalid "exports" target') + }) + + it('reports an unavailable Node resolver without changing plugin state', () => { + vi.spyOn(ModuleLoader, 'fromInternal').mockReturnValue(undefined) + expect(readPluginMeta('localized', parentURL)?.error).toContain('requires the Node module resolver') + }) + + it.each(['v1', 'v2'] as const)('uses the %s Node resolver argument order', (version) => { + const loader = ModuleLoader.fromInternal()! + dictionary('en', { meta: { title: 'Resolved resource', description: 'Resolved introduction' } }) + const resolveSync = vi.fn(() => ({ url: pathToFileURL(join(dir, 'locale', 'en.json')).href })) + const adapted = new Proxy(loader, { + get(target, property) { + if (property === 'version') return version + if (property === 'resolveSync') return resolveSync + const value: unknown = Reflect.get(target, property) + return value + }, + }) + vi.spyOn(ModuleLoader, 'fromInternal').mockReturnValue(adapted) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: { en: 'Resolved resource' }, description: { en: 'Resolved introduction' }, + }) + const specifier = 'localized/locale/en.json' + expect(resolveSync.mock.calls).toEqual(version === 'v1' + ? [[specifier, parentURL, {}], [specifier, parentURL, {}]] + : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]]) + }) + + it('rejects case-equivalent language names in a directory listing', () => { + dictionary('en', { meta: { title: 'Title' } }) + const localeDir = dirname(resolvePluginResource('localized/locale/en.json', parentURL)) + const original = fs.readdirSync + const english = original(localeDir, { withFileTypes: true })[0]! + const duplicate = original(localeDir, { withFileTypes: true })[0]! + duplicate.name = 'EN.json' + // Case-insensitive filesystems cannot store both names in the same directory. + const listing = vi.spyOn(fs, 'readdirSync') + try { + listing.mockImplementation(new Proxy(original, { + apply(target, receiver: unknown, args: unknown[]): unknown { + if (args[0] === localeDir) return [english, duplicate] + return Reflect.apply(target, receiver, args) + }, + })) + syncBuiltinESMExports() + expect(fs.readdirSync(dir, { withFileTypes: true })).toEqual(original(dir, { withFileTypes: true })) + expect(readPluginMeta('localized', parentURL)?.error).toContain('localized/locale/EN.json duplicates locale en') + } finally { + listing.mockRestore() + syncBuiltinESMExports() + } + expect(fs.readdirSync).toBe(original) + expect(readdirSync).toBe(original) + expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Title' } }) + }) + + it('rejects case-equivalent language files on case-sensitive filesystems', (context) => { + dictionary('en', { meta: { title: 'Title' } }) + dictionary('EN', { meta: { title: 'Other' } }) + // Case-insensitive filesystems cannot hold both filenames. + if (readdirSync(join(dir, 'locale')).length !== 2) context.skip() + expect(readPluginMeta('localized', parentURL)?.error).toContain('duplicates locale en') + }) +}) diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts index 5d7f34b876..1be59df4f1 100644 --- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts +++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts @@ -29,7 +29,11 @@ function pkg(dir: string, version: string, name = 'metadata-lib'): string { name, version, type: 'module', - exports: { import: './index.js', require: './index.cjs' }, + exports: { + '.': { import: './index.js', require: './index.cjs' }, + './feature': './index.js', + './feature/locale/*.json': './feature-locale/*.json', + }, })) file(join(dir, 'index.js'), `export const version = ${JSON.stringify(version)}\n`) file(join(dir, 'index.cjs'), `exports.version = ${JSON.stringify(version)}\n`) @@ -48,6 +52,27 @@ function resolution( } describe('profile package metadata service', () => { + it('reads translated metadata from the selected local package without importing its entry', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-localized-package-service-')) + roots.push(root) + const packageDir = join(root, 'node_modules', 'localized') + file(join(packageDir, 'package.json'), JSON.stringify({ + name: 'localized', exports: { './private': './index.js', './private/locale/*.json': './locale/*.json' }, + })) + file(join(packageDir, 'index.js'), 'throw new Error("must not execute")\n') + file(join(packageDir, 'locale', 'en.json'), '{"meta":{"title":"Local plugin"}}') + file(join(packageDir, 'locale', 'zh.json'), '{"meta":{"title":"本地插件"}}') + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(PluginPackages) + const parent = pathToFileURL(join(root, 'entry.mjs')).href + expect(ctx.pluginPackages.metaOf('localized/private', parent)).toEqual({ title: { en: 'Local plugin', zh: '本地插件' } }) + expect(ctx.pluginPackages.metaOf('localized', parent)).toBeUndefined() + expect(ctx.pluginPackages.metaOf('node:fs', parent)).toBeUndefined() + file(join(root, 'node_modules', 'invalid', 'package.json'), '{') + expect(ctx.pluginPackages.metaOf('invalid', parent)?.error).toContain('invalid') + }) + it('resolves module URLs and package metadata through the current resolution', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-profile-package-service-')) roots.push(root) @@ -55,6 +80,7 @@ describe('profile package metadata service', () => { const profileDir = join(profilesDir, 'test') const first = join(root, 'first') const firstAnchor = pkg(first, '1.0.0') + file(join(first, 'feature-locale', 'en.json'), '{"meta":{"title":"Profile feature"}}') file(join(profileDir, 'entry.mjs'), '') const parentURL = pathToFileURL(join(profileDir, 'entry.mjs')).href @@ -72,6 +98,7 @@ describe('profile package metadata service', () => { }) const require = createRequire(join(profileDir, 'entry.cjs')) expect(require.resolve('metadata-lib')).toBe(realpathSync(join(first, 'index.cjs'))) + expect(ctx.pluginPackages.metaOf('metadata-lib/feature', parentURL)).toEqual({ title: { en: 'Profile feature' } }) expect(ctx.pluginPackages.packageOf('node:fs', parentURL)).toBeUndefined() expect(ctx.pluginPackages.packageOf('./local.js', parentURL)).toBeUndefined() diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index b0a5065400..ccbb9439ef 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -86,6 +86,7 @@ } }, "devDependencies": { + "@deepseek-ai/dsh-package-manifest": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index e470ccdc93..e03b361dc7 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { readFile, rm } from 'node:fs/promises' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { Context } from '@deepseek-ai/cordis' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -11,7 +12,8 @@ import z from '@deepseek-ai/schemastery' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory' import { - readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, + readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, + reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-hmr' import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot' @@ -232,8 +234,8 @@ 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. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. - * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the - * bundle, and removal availability. + * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections, + * whether the installation offers the bundle, and removal availability. */ @Remote listBundles(): Promise { @@ -256,8 +258,11 @@ export class PluginManager extends TypertRemoteService { ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } + 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 }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), + ...meta === undefined ? {} : { meta }, enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info) }) @@ -557,16 +562,23 @@ export class PluginManager extends TypertRemoteService { const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir) const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch)) // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last. - const live = new Map() + const live = new Map() for (const entry of this.ctx.loader.entries()) { /* v8 ignore next -- the Loader gives every entry an id before it is listed */ - if (typeof entry.options.id === 'string') live.set(entry.options.id, pluginEntryId(entry.id)) + if (typeof entry.options.id === 'string') live.set(entry.options.id, { + entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl, + }) } const rows: BundleRowInfo[] = [] + const packages = this.ctx.get('pluginPackages') for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) { if (typeof row.id !== 'string' || typeof row.name !== 'string') continue - const entryId = live.get(row.id) - rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } }) + const active = live.get(row.id) + const entryId = active?.entryId + const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href + const meta = packages?.metaOf(row.name, base) + rows.push({ rowId: row.id, moduleName: row.name, + ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } }) } const declared = new Set(rows.map(row => row.rowId)) const overrides = [...new Set(patches.flatMap(item => diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts index a5b1cd6837..2b65dd8bc2 100644 --- a/packages/boot/plugin-manager/src/tools.ts +++ b/packages/boot/plugin-manager/src/tools.ts @@ -49,7 +49,10 @@ export function apply(ctx: Context): void { throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100') } const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles() - const entries = rows.slice(offset, offset + limit) + const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) => + 'rows' in row + ? { ...row, rows: row.rows.map(({ meta: _rowMeta, ...declared }) => declared) } + : row) return JSON.stringify({ entries, total: rows.length, nextOffset: offset + entries.length < rows.length ? offset + entries.length : null }) } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 9506dde749..c80d9b5ab2 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -1,5 +1,6 @@ /** Public plugin management records shared with clients. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { PackageMeta } from '@deepseek-ai/dsh-package-manifest' import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' @@ -25,6 +26,8 @@ export interface BundleRowInfo { rowId: string /** The module the row names. */ moduleName: string + /** Local package display metadata, including rows whose bundle is disabled. */ + meta?: PackageMeta /** The Loader entry carrying this row, when exactly one live entry has its id. */ entryId?: PluginEntryId } @@ -33,7 +36,9 @@ export interface BundleRowInfo { export interface BundleInfo { name: string version?: string - /** `description` of the package manifest. */ + /** Local display text with available translations or literal fallbacks, or a metadata diagnostic. */ + meta?: PackageMeta + /** Untranslated `description` of this bundle's package manifest. */ description?: string /** Selected in the profile manifest; a load error means its layer was skipped. */ enabled: boolean diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 028c28917e..19966e47b8 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1,5 +1,5 @@ /** Persistent manager behavior through a real profile Include and Loader. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { realpath } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -8,8 +8,8 @@ import type { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' import { boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest, - reconcileProfilePatches, OPTIONAL_BUNDLES, - type ProfileContext, + reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta, + 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' import Hmr from '@deepseek-ai/dsh-hmr' @@ -20,8 +20,11 @@ import * as operations from '../src/operations.ts' import { parse, parseDocument } from 'yaml' async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) { + const temporaryHome = mkdtempSync(join(tmpdir(), 'plugin-manager-')) + let owner: Context | undefined + onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(temporaryHome, { recursive: true, force: true }) }) // pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases. - const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-'))) + const home = await realpath(temporaryHome) const dir = join(home, 'profiles', 'test') const anchor = join(home, 'package.json') writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n') @@ -49,12 +52,12 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre overlays, telemetryDisabledEnv: undefined, } const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => { + owner = ctx ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } }) prepare?.(ctx) ctx.provide('profileContext', profile) ctx.loader.builtins.manager = PluginManager }) - onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) }) // What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry. const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/') onTestFinished(() => { registry.mockRestore() }) @@ -136,10 +139,12 @@ it('lists bundle versions and current-profile plugin targets', async () => { expect(await manager.listBundles()).toEqual([ { name: 'core', version: '1.0.0', enabled: true, installed: false, 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', enabled: true, installed: true, optional: false, removable: true, + meta: { title: 'extra' }, rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], }, ]) @@ -161,6 +166,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({ name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true, + meta: { title: 'described', description: 'Describes itself.' }, rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], }) await manager.setBundleEnabled('described', true) @@ -172,6 +178,90 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }]) }) +it.each(['native', 'runtime'] as const)('reads a disabled bundle and its independent exported plugins without running them with %s resolution', async (mode) => { + const { ctx, manager, dir, bundle, profile } = await fixture('startup') + bundle('localized', [{ id: 'first', name: 'local-child/first' }, { id: 'second', name: 'local-child/second' }]) + const root = join(dir, 'node_modules', 'localized') + const child = mode === 'runtime' + ? join(profile.home, 'installation', 'node_modules', 'local-child') + : join(root, 'node_modules', 'local-child') + mkdirSync(join(root, 'locale'), { recursive: true }) + mkdirSync(join(child, 'first-locale'), { recursive: true }) + mkdirSync(join(child, 'second-locale'), { recursive: true }) + writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'localized', + exports: { './locale/*.json': './locale/*.json' }, dsh: { + bundle: { patch: './cordis.patch.yml' }, + } })) + writeFileSync(join(root, 'locale', 'en.json'), '{"meta":{"title":"Local bundle"}}') + writeFileSync(join(root, 'locale', 'zh.json'), '{"meta":{"title":"本地组合包"}}') + writeFileSync(join(child, 'package.json'), JSON.stringify({ name: 'local-child', exports: { + './first': './index.js', './second': './index.js', + './first/locale/*.json': './first-locale/*.json', './second/locale/*.json': './second-locale/*.json', + } })) + writeFileSync(join(child, 'index.js'), 'throw new Error("must not execute")\n') + writeFileSync(join(child, 'first-locale', 'en.json'), '{"meta":{"title":"First plugin"}}') + writeFileSync(join(child, 'second-locale', 'en.json'), '{"meta":{"title":"Second plugin"}}') + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, localized: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + const parentURL = pathToFileURL(join(root, 'package.json')).href + const readChildren = () => ['first', 'second'].map(part => readPluginMeta(`local-child/${part}`, parentURL)) + const expectUnlinked = () => { + expect(lstatSync(child).isDirectory()).toBe(true) + for (const path of [ + join(root, 'node_modules'), join(dir, 'node_modules', 'local-child'), + join(profile.home, 'profiles', 'node_modules'), join(profile.home, 'node_modules'), + join(dir, '.dsh-module-fallback'), + ]) expect(lstatSync(path, { throwIfNoEntry: false }), path).toBeUndefined() + } + const resolution: RuntimeResolution = { + profilesDir: join(profile.home, 'profiles'), profileDir: dir, + localPackageNames: Object.keys(manifest.dependencies), + entries: [{ name: 'local-child', packageDir: child, version: undefined, + declarer: join(child, 'package.json'), scope: 'installation' }], + } + if (mode === 'runtime') { + expectUnlinked() + expect(readChildren()).toEqual([undefined, undefined]) + } + await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {}) + const result = (await manager.listBundles()).find(row => row.name === 'localized') + expect(result).toMatchObject({ enabled: false, meta: { title: { en: 'Local bundle', zh: '本地组合包' } }, rows: [ + { rowId: 'first', moduleName: 'local-child/first', meta: { title: { en: 'First plugin' } } }, + { rowId: 'second', moduleName: 'local-child/second', meta: { title: { en: 'Second plugin' } } }, + ] }) + expect(result?.rows[0]?.entryId).toBeUndefined() + expect(readPluginMeta('local-child/private', parentURL)).toBeUndefined() + if (mode === 'runtime') { + expectUnlinked() + await ctx.fiber.dispose() + expect(readChildren()).toEqual([undefined, undefined]) + expectUnlinked() + } +}) + +it.each([ + { resources: 'exported', exports: { './locale/*.json': './locale/*.json' }, expected: { meta: { title: { en: 'Nameless bundle' } } } }, + { resources: 'private', exports: {}, expected: {} }, +])('lists a bundle without a manifest name with $resources locale resources', async ({ exports, expected }) => { + const { manager, dir, bundle } = await fixture('startup') + bundle('unnamed', []) + const root = join(dir, 'node_modules', 'unnamed') + mkdirSync(join(root, 'locale')) + writeFileSync(join(root, 'package.json'), JSON.stringify({ + version: '1.0.0', exports, dsh: { bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(join(root, 'locale', 'en.json'), '{"meta":{"title":"Nameless bundle"}}') + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, unnamed: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + + expect((await manager.listBundles()).find(row => row.name === 'unnamed')).toEqual({ + name: 'unnamed', version: '1.0.0', enabled: false, installed: true, optional: false, removable: true, + rows: [], overrides: [], ...expected, + }) +}) + it('turns a plugin off and on without duplicating patch overrides', async () => { const { manager, dir } = await fixture() const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId @@ -767,6 +857,7 @@ it('offers the launcher\'s optional bundles switched off and never removable', a writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { [offered]: '3.0.0' } })) expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({ name: offered, version: '3.0.0', description: 'Package one-liner.', + meta: { title: offered, description: 'Package one-liner.' }, enabled: false, installed: false, optional: true, removable: false, rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [], }) diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index c3656f4fd5..da2221433d 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -185,6 +185,21 @@ it('paginates inventories with an explicit continuation and total', async () => expect(resultText(await call({ action: 'list_bundles' }))).toContain('"name":"bundle"') }) +it('keeps UI translation metadata out of model-facing plugin and bundle lists', async () => { + const { call, manager } = await fixture() + const meta = { title: { en: 'Plugin', zh: '插件' }, error: 'UI-only diagnostic' } + manager.listPlugins.mockImplementationOnce(async () => [{ entryId: 'include:plugin', enabled: true, meta }]) + expect(JSON.parse(resultText(await call({ action: 'list_plugins' })))).toEqual({ + entries: [{ entryId: 'include:plugin', enabled: true }], total: 1, nextOffset: null, + }) + manager.listBundles.mockImplementationOnce(async () => [{ + name: 'bundle', enabled: true, meta, rows: [{ rowId: 'plugin', moduleName: 'plugin', meta }], + }]) + expect(JSON.parse(resultText(await call({ action: 'list_bundles' })))).toEqual({ + entries: [{ name: 'bundle', enabled: true, rows: [{ rowId: 'plugin', moduleName: 'plugin' }] }], total: 1, nextOffset: null, + }) +}) + it('forwards all mutation actions and renders the returned outcome', async () => { const { call, manager } = await fixture() await call({ action: 'set_plugin', target: 'include:1', enabled: false }) diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json index 32b49b766e..ba181795a1 100644 --- a/packages/boot/plugin-manager/tsconfig.json +++ b/packages/boot/plugin-manager/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 7d28dc02f6..31a504845d 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -59,6 +59,8 @@ } }, "devDependencies": { + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-package-manifest": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index 83d8a1cc97..0600f51536 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -4,6 +4,7 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' // Type-only: the optional agent-preset roster resolved through `ctx.get`. import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-app-boot' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' @@ -63,8 +64,8 @@ export class PluginInventoryGateway extends TypertRemoteService { * preset's composition rows, because those rows — not the Loader's own * entries — are where a deployment that mounts the roster runs its * model-facing plugins. - * @returns Current non-group Loader entries in Loader order, with per-preset - * compositions when a roster is composed. + * @returns Current non-group Loader entries in Loader order, with optional display metadata + * and per-preset compositions when a roster is composed. */ @Remote('list') async list(): Promise { @@ -76,17 +77,21 @@ export default PluginInventoryGateway /** Read current Loader entries and optional preset compositions. * @param ctx Context with the Loader service. - * @returns Current inventory without a separate runtime cache. + * @returns Current inventory with optional display metadata and no separate runtime cache. */ export async function readPluginInventory(ctx: Context): Promise { const entries: PluginInventoryEntry[] = [] + const packages = ctx.get('pluginPackages') for (const entry of ctx.loader.entries()) { if (entry.options.group) continue + const base = entry.parent.tree.ctx.baseUrl + const meta = base === undefined ? undefined : packages?.metaOf(entry.options.name, base) entries.push({ entryId: pluginEntryId(entry.id), moduleName: entry.options.name, enabled: !entry.disabled, fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + ...meta === undefined ? {} : { meta }, }) } const presets = ctx.get('agentPresets') @@ -95,10 +100,14 @@ export async function readPluginInventory(ctx: Context): Promise ({ ...composition, - rows: composition.rows.map(({ fiberState, ...row }) => ({ - ...row, - fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState], - })), + rows: composition.rows.map(({ fiberState, ...row }) => { + const meta = ctx.baseUrl === undefined ? undefined : packages?.metaOf(row.moduleName, ctx.baseUrl) + return { + ...row, + fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState], + ...meta === undefined ? {} : { meta }, + } + }), }), ) return { entries, agentPresets, ...management } diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index 3acdb75068..f35322986d 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -1,4 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { PackageMeta } from '@deepseek-ai/dsh-package-manifest' /** Stable Loader-tree identity of one configured plugin entry. */ export type PluginEntryId = Branded<'PluginEntryId'> @@ -17,6 +18,8 @@ export interface PluginInventoryEntry { readonly entryId: PluginEntryId /** Exact module specifier imported by the Loader entry. */ readonly moduleName: string + /** Local package display metadata, independent of whether the entry is enabled. */ + readonly meta?: PackageMeta /** Effective Loader enablement, including disabled ancestor groups. */ readonly enabled: boolean readonly fiberPhase: PluginFiberPhase @@ -31,6 +34,8 @@ export interface AgentPresetPluginRow { readonly entryId: string | null /** Module specifier the row names. */ readonly moduleName: string + /** Local package display metadata, independent of whether the preset is mounted. */ + readonly meta?: PackageMeta /** * Effective enablement, including disabled ancestor groups. `'conditional'` * marks a `!!js` disabled expression on a composition no session has diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 1887897246..c403d6fe76 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -1,14 +1,21 @@ import { afterEach, describe, expect, it } from 'vitest' +import { lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' +import { PluginPackages, readPluginMeta, type RuntimeResolution } from '@deepseek-ai/dsh-app-boot' import Loader from '@deepseek-ai/cordis-plugin-loader' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' import PluginInventoryGateway from '../src/index.ts' const contexts: Context[] = [] +const roots: string[] = [] afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) const activePlugin: Plugin.Function = () => {} @@ -17,12 +24,13 @@ const pendingPlugin: Plugin.Object = { apply() {}, } -async function harness(): Promise<{ +async function harness(baseUrl?: string): Promise<{ ctx: Context inventory: PluginInventoryGateway }> { const ctx = new Context() contexts.push(ctx) + if (baseUrl !== undefined) ctx.baseUrl = baseUrl await ctx.plugin(Loader) ctx.loader.builtins.active = activePlugin ctx.loader.builtins.pending = pendingPlugin @@ -31,7 +39,105 @@ async function harness(): Promise<{ return { ctx, inventory } } +function metadataFixture(mode: 'native' | 'runtime') { + const root = mkdtempSync(join(tmpdir(), 'dsh-inventory-meta-')) + roots.push(root) + const profilesDir = join(root, 'profiles') + const profileDir = join(profilesDir, 'test') + const baseDir = mode === 'runtime' ? profileDir : root + mkdirSync(baseDir, { recursive: true }) + const baseUrl = pathToFileURL(join(baseDir, 'entry.mjs')).href + const dir = mode === 'runtime' + ? join(root, 'installation', 'node_modules', 'local-plugin') + : join(root, 'node_modules', 'local-plugin') + const resolution: RuntimeResolution = { + profilesDir, profileDir, localPackageNames: [], + entries: [{ name: 'local-plugin', packageDir: dir, version: undefined, + declarer: join(dir, 'package.json'), scope: 'installation' }], + } + const expectUnlinked = () => { + expect(lstatSync(dir).isDirectory()).toBe(true) + for (const path of [ + join(profileDir, 'node_modules'), join(profilesDir, 'node_modules'), + join(root, 'node_modules'), join(profileDir, '.dsh-module-fallback'), + ]) expect(lstatSync(path, { throwIfNoEntry: false }), path).toBeUndefined() + } + return { dir, baseUrl, resolution, expectUnlinked } +} + describe('PluginInventoryGateway', () => { + it.each(['native', 'runtime'] as const)('reads local metadata while the package entry remains disabled with %s resolution', async (mode) => { + const { dir, baseUrl, resolution, expectUnlinked } = metadataFixture(mode) + mkdirSync(join(dir, 'locale'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'local-plugin', exports: { './feature': './index.js', './feature/locale/*.json': './locale/*.json' }, + })) + writeFileSync(join(dir, 'index.js'), 'throw new Error("metadata must not execute the plugin")\n') + writeFileSync(join(dir, 'locale', 'en.json'), '{"meta":{"title":"Local plugin","description":"Local description"}}') + if (mode === 'runtime') { + expectUnlinked() + expect(readPluginMeta('local-plugin/feature', baseUrl)).toBeUndefined() + } + const { ctx, inventory } = await harness(baseUrl) + await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {}) + await ctx.loader.create({ name: 'local-plugin/feature', disabled: true }) + expect((await inventory.list()).entries).toMatchObject([{ + enabled: false, fiberPhase: null, meta: { title: { en: 'Local plugin' }, description: { en: 'Local description' } }, + }]) + if (mode === 'runtime') { + expectUnlinked() + await ctx.fiber.dispose() + expect(readPluginMeta('local-plugin/feature', baseUrl)).toBeUndefined() + expectUnlinked() + } + }) + + it.each(['native', 'runtime'] as const)('resolves preset row metadata from the profile base without loading the plugins with %s resolution', async (mode) => { + const { dir, baseUrl, resolution, expectUnlinked } = metadataFixture(mode) + mkdirSync(join(dir, 'locale'), { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: 'local-plugin', exports: { './feature': './index.js', './feature/locale/*.json': './locale/*.json' }, + })) + writeFileSync(join(dir, 'index.js'), 'throw new Error("metadata must not execute the plugin")\n') + writeFileSync(join(dir, 'locale', 'en.json'), '{"meta":{"title":"Preset plugin","description":"Preset description"}}') + writeFileSync(join(dir, 'locale', 'zh.json'), '{"meta":{"title":"预设插件"}}') + if (mode === 'runtime') { + expectUnlinked() + expect(readPluginMeta('local-plugin/feature', baseUrl)).toBeUndefined() + } + const { ctx, inventory } = await harness(baseUrl) + await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {}) + ctx.provide('agentPresets', { + compositionInventory: async () => [{ + id: 'local', trust: 'system', isDefault: true, + rows: [ + { entryId: 'feature', moduleName: 'local-plugin/feature', enabled: false }, + { entryId: null, moduleName: 'local-plugin/private', enabled: false }, + ], + }], + } as Partial as never) + + expect(await inventory.list()).toEqual({ + entries: [], + agentPresets: [{ + id: 'local', trust: 'system', isDefault: true, + rows: [ + { + entryId: 'feature', moduleName: 'local-plugin/feature', enabled: false, fiberPhase: null, + meta: { title: { en: 'Preset plugin', zh: '预设插件' }, description: { en: 'Preset description' } }, + }, + { entryId: null, moduleName: 'local-plugin/private', enabled: false, fiberPhase: null }, + ], + }], + }) + if (mode === 'runtime') { + expectUnlinked() + await ctx.fiber.dispose() + expect(readPluginMeta('local-plugin/feature', baseUrl)).toBeUndefined() + expectUnlinked() + } + }) + it('publishes one direct list method under the pluginInventory namespace', async () => { const { inventory } = await harness() expect(inventory.typertRemote).toMatchObject({ diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 15028f25f0..75b1f99127 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -8,6 +8,12 @@ "src" ], "references": [ + { + "path": "../../boot/app-boot" + }, + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/util/package-manifest/src/index.ts b/packages/util/package-manifest/src/index.ts index b14885a03f..3be3802d88 100644 --- a/packages/util/package-manifest/src/index.ts +++ b/packages/util/package-manifest/src/index.ts @@ -1,5 +1,5 @@ /** - * Public package manifest types, with no runtime exports. + * Public package manifest and plugin display types, with no runtime exports. * @module @deepseek-ai/dsh-package-manifest */ @@ -10,4 +10,6 @@ export type { DshManifest, DshPackageManifest, DshProfileManifest, + LocalizedText, + PackageMeta, } from './types.ts' diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index 7063f2f0ce..96b3a29466 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -1,5 +1,5 @@ /** - * Shared declarations for the package.json fields used by DSH plugin authors. + * Shared declarations for package.json fields and plugin display metadata. * Each reader owns JSON validation and resolved defaults. * @module @deepseek-ai/dsh-package-manifest/types */ @@ -36,6 +36,19 @@ export interface DshManifest { client?: DshClientManifest } +/** Literal text or translations indexed by lowercase language id, with a required English fallback. */ +export type LocalizedText = string | { readonly en: string; readonly [locale: string]: string } + +/** Validated plugin display fields or a diagnostic from reading exported locale files. */ +export interface PackageMeta { + /** Display title; omission preserves the consumer's technical-name fallback. */ + readonly title?: LocalizedText + /** Display introduction after locale and package-field fallback. */ + readonly description?: LocalizedText + /** Unmodified local metadata diagnostic; the plugin remains manageable. */ + readonly error?: string +} + /** Runtime version requirements under `package.json.engines`. */ export interface DshEnginesManifest { /** Compatible DSH versions as a SemVer range, including an exact version. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7463110f..01edd8cb6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1558,6 +1558,9 @@ importers: '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory + '@deepseek-ai/dsh-package-manifest': + specifier: workspace:^ + version: link:../../util/package-manifest '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -7830,9 +7833,15 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-package-manifest': + specifier: workspace:^ + version: link:../../util/package-manifest '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol From 0d611ea15114ec11225f7b64861c91f05f4f5b70 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:56:01 +0800 Subject: [PATCH 05/10] feat(web): display plugin-owned locale metadata --- .../plugin-config/official.expected.md | 4 +- .../expected/plugin-config/row.expected.md | 6 +- .../plugin-manager/exports-en.expected.md | 24 ++ .../plugin-manager/exports.expected.md | 25 ++ .../plugin-manager/live-enabled.expected.md | 6 +- .../plugin-manager/manager.expected.md | 6 +- .../plugin-manager/missing-bundle.expected.md | 5 +- .../plugin-instances.expected.md | 10 +- .../settings-chrome/plugins.expected.md | 2 +- .../plugins/fixture-bundle/cordis.patch.yml | 6 +- .../fixture-bundle/locale/review/en.json | 3 + .../fixture-bundle/locale/review/zh.json | 6 + .../fixture-bundle/locale/search/en.json | 5 + .../fixture-bundle/locale/search/zh.json | 6 + .../plugins/fixture-bundle/package.json | 11 +- .../fixtures/plugins/fixture-bundle/review.js | 2 + .../fixtures/plugins/fixture-bundle/search.js | 2 + .../fixture-bundle/search.package.json | 4 + apps/web/tests/plugin-config.e2e.ts | 13 +- apps/web/tests/plugin-manager.e2e.ts | 106 ++++-- apps/web/tests/settings-chrome.e2e.ts | 5 +- packages/client/locale/package.json | 1 + packages/client/locale/src/client/index.ts | 15 + .../tests/localized-text.client.spec.ts | 64 ++++ packages/client/locale/tsconfig.json | 3 + .../client/ui-plugin-manager/package.json | 1 + .../src/client/PluginManagerPage.tsx | 91 +++-- .../ui-plugin-manager/src/client/index.ts | 2 +- .../ui-plugin-manager/src/client/locales.ts | 14 +- .../src/client/manager-store.ts | 13 +- .../src/client/presentation.ts | 48 ++- .../src/client/slot-contract.ts | 10 +- .../tests/browser-plugin.client.spec.tsx | 8 +- .../tests/components.client.spec.tsx | 342 ++++++++++++++---- .../tests/manager-store.client.spec.ts | 24 +- .../client/ui-plugin-manager/tsconfig.json | 3 + .../ui-settings-plugin-inventory/package.json | 1 + .../src/client/PluginInventorySettingsTab.tsx | 52 ++- .../src/client/index.ts | 1 + .../src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.tsx | 8 +- .../tests/components.client.spec.tsx | 225 +++++++++++- .../tsconfig.json | 3 + .../agent-team-profile/locale/en.json | 6 + .../agent-team-profile/locale/zh.json | 6 + .../agent-team-profile/package.json | 2 + .../experimental/agent-team/locale/en.json | 6 + .../experimental/agent-team/locale/zh.json | 6 + packages/experimental/agent-team/package.json | 2 + .../experimental/auto-review/locale/en.json | 6 + .../experimental/auto-review/locale/zh.json | 6 + .../experimental/auto-review/package.json | 2 + .../client-ui-agent-team/locale/en.json | 6 + .../client-ui-agent-team/locale/zh.json | 6 + .../client-ui-agent-team/package.json | 2 + .../tool-agent-team/locale/en.json | 6 + .../tool-agent-team/locale/zh.json | 6 + .../experimental/tool-agent-team/package.json | 2 + pnpm-lock.yaml | 9 + 59 files changed, 1067 insertions(+), 200 deletions(-) create mode 100644 apps/web/tests/expected/plugin-manager/exports-en.expected.md create mode 100644 apps/web/tests/expected/plugin-manager/exports.expected.md create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/review.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/search.js create mode 100644 apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json create mode 100644 packages/client/locale/tests/localized-text.client.spec.ts create mode 100644 packages/experimental/agent-team-profile/locale/en.json create mode 100644 packages/experimental/agent-team-profile/locale/zh.json create mode 100644 packages/experimental/agent-team/locale/en.json create mode 100644 packages/experimental/agent-team/locale/zh.json create mode 100644 packages/experimental/auto-review/locale/en.json create mode 100644 packages/experimental/auto-review/locale/zh.json create mode 100644 packages/experimental/client-ui-agent-team/locale/en.json create mode 100644 packages/experimental/client-ui-agent-team/locale/zh.json create mode 100644 packages/experimental/tool-agent-team/locale/en.json create mode 100644 packages/experimental/tool-agent-team/locale/zh.json diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 3b7a533f1a..d062b53002 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -25,5 +25,5 @@ - text: "1" - list: - listitem: - - button "查看 live-client": live-client - - switch "启用 live-client" + - button "查看 @fixture/live-client": "@fixture/live-client" + - switch "启用 @fixture/live-client" diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md index 85b1d52b9e..03308874b2 100644 --- a/apps/web/tests/expected/plugin-config/row.expected.md +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -1,5 +1,7 @@ -- button "返回 live-client": live-client -- heading "fixture-live-client" [level=3] +- button "返回 @fixture/live-client": "@fixture/live-client" +- heading "@fixture/live-client" [level=3] +- paragraph: + - code: fixture-live-client - paragraph: - code: "@fixture/live-client" - paragraph: 示例配置项 diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md new file mode 100644 index 0000000000..74cc1b4c15 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -0,0 +1,24 @@ +- button "Back to plugins": Plugins +- button "Uninstall @fixture/bundle": Uninstall +- switch "Enable @fixture/bundle" +- heading "@fixture/bundle" [level=3] +- text: v0.0.1 +- paragraph: + - code: "@fixture/bundle" +- paragraph: Registry description for the fixture bundle. +- heading "Components" [level=4] +- text: 3 total · 3 off +- list: + - listitem: + - text: "@fixture/bundle Registry description for the fixture bundle." + - code: fixture-row + - text: "Off" + - listitem: + - text: File Search Search package introduction. + - code: fixture-search + - code: "@fixture/bundle/search" + - text: "Off" + - listitem: + - text: "@fixture/bundle/review" + - code: fixture-review + - text: "Off" diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md new file mode 100644 index 0000000000..0512893d3e --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -0,0 +1,25 @@ +- button "返回插件列表": 插件列表 +- button "卸载 @fixture/bundle": 卸载 +- switch "启用 @fixture/bundle" +- heading "@fixture/bundle" [level=3] +- text: v0.0.1 +- paragraph: + - code: "@fixture/bundle" +- paragraph: Registry description for the fixture bundle. +- heading "包含的组件" [level=4] +- text: 共 3 个 · 3 已停用 +- list: + - listitem: + - text: "@fixture/bundle Registry description for the fixture bundle." + - code: fixture-row + - text: 已关闭 + - listitem: + - text: 文件搜索 搜索工作区中的文件。 + - code: fixture-search + - code: "@fixture/bundle/search" + - text: 已关闭 + - listitem: + - text: 代码审查 审查工作区中的改动。 + - code: fixture-review + - code: "@fixture/bundle/review" + - text: 已关闭 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index ae850f5daa..92fafe32cb 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -25,6 +25,6 @@ - text: "1" - list: - listitem: - - button "查看 bundle": bundle - - text: "Web e2e fixture: a bundle whose one row is an inert plugin." - - switch "启用 bundle" [checked] + - button "查看 @fixture/bundle": "@fixture/bundle" + - text: Registry description for the fixture bundle. + - switch "启用 @fixture/bundle" [checked] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index a13d1a001e..45658f20cf 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -25,6 +25,6 @@ - text: "1" - list: - listitem: - - button "查看 bundle": bundle - - text: "Web e2e fixture: a bundle whose one row is an inert plugin." - - switch "启用 bundle" + - button "查看 @fixture/bundle": "@fixture/bundle" + - text: Registry description for the fixture bundle. + - switch "启用 @fixture/bundle" diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md index 5eec14673b..93ebcf1143 100644 --- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md +++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md @@ -1,10 +1,9 @@ - button "返回插件列表": 插件列表 -- switch "启用 missing-bundle" [checked] -- heading "missing-bundle" [level=3] +- switch "启用 @fixture/missing-bundle" [checked] +- heading "@fixture/missing-bundle" [level=3] - text: 异常 - paragraph: - code: "@fixture/missing-bundle" -- paragraph: 暂无描述。 - status: "原因: dsh: cannot resolve profile bundle \"@fixture/missing-bundle\" from the dsh installation or {{home}}/profiles/scaffold; run 'dsh plugin --profile scaffold install' if its dependency is not installed" - heading "包含的组件" [level=4] - paragraph: 这个插件包不包含任何组件。 diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md index 4c654a7971..612291fe84 100644 --- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -2,7 +2,7 @@ - listitem: - button "tool-subagent-control, tool-subagent-control, 已启用": - strong: tool-subagent-control - - text: 已启用 + - text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations - code: tool-subagent-control - listitem: - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用": @@ -12,20 +12,20 @@ - listitem: - button "tool-subagent, tool-subagent, 已启用": - strong: tool-subagent - - text: 已启用 + - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam - code: tool-subagent - listitem: - button "tool-subagent, tool-subagent-fork, 已启用": - strong: tool-subagent - - text: 已启用 + - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam - code: tool-subagent-fork - listitem: - button "tool-subagent, tool-subagent-codex, 已停用": - strong: tool-subagent - - text: 已停用 + - text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam - code: tool-subagent-codex - listitem: - button "tool-subagent, tool-subagent-claude-code, 已停用": - strong: tool-subagent - - text: 已停用 + - text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam - code: tool-subagent-claude-code diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index e8536c690a..7cfe33f5f0 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -1,5 +1,5 @@ - listitem: - button "tool-subagent, tool-subagent, 已启用": - strong: tool-subagent - - text: 已启用 + - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam - code: tool-subagent diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml index a3e71146b9..8038d9e378 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml @@ -1,4 +1,8 @@ -# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes. +# Web e2e fixture bundle with independently localized plugin exports. - insert: - id: fixture-row name: '@fixture/bundle' + - id: fixture-search + name: '@fixture/bundle/search' + - id: fixture-review + name: '@fixture/bundle/review' diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json new file mode 100644 index 0000000000..6da7422b1a --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json @@ -0,0 +1,3 @@ +{ + "meta": {} +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json new file mode 100644 index 0000000000..90a5b56fb2 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "代码审查", + "description": "审查工作区中的改动。" + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json new file mode 100644 index 0000000000..e27e893200 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "File Search" + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json new file mode 100644 index 0000000000..44f2e03849 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件搜索", + "description": "搜索工作区中的文件。" + } +} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 263634fb08..3224dfa296 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -2,9 +2,18 @@ "name": "@fixture/bundle", "version": "0.0.1", "private": true, - "description": "Web e2e fixture: a bundle whose one row is an inert plugin.", + "description": "Registry description for the fixture bundle.", "type": "module", "main": "index.js", + "exports": { + ".": "./index.js", + "./package.json": "./package.json", + "./search": "./search.js", + "./search/package.json": "./search.package.json", + "./review": "./review.js", + "./search/locale/*.json": "./locale/search/*.json", + "./review/locale/*.json": "./locale/review/*.json" + }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/review.js b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js new file mode 100644 index 0000000000..356cb5cf9a --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js @@ -0,0 +1,2 @@ +/** Inert review plugin for exported metadata browser coverage. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.js b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js new file mode 100644 index 0000000000..c8858a7889 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js @@ -0,0 +1,2 @@ +/** Inert search plugin for exported metadata browser coverage. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json new file mode 100644 index 0000000000..52a0c0f420 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json @@ -0,0 +1,4 @@ +{ + "name": "Search plugin", + "description": "Search package introduction." +} diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 825d95b081..11394b867b 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -33,6 +33,7 @@ describe('web e2e: plugin configuration pages', () => { // The live-client fixture is a bundle with a browser half; switched on // below, that half registers its row's configuration into the page. scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, }) browser = await chromium.launch() @@ -286,21 +287,21 @@ describe('web e2e: plugin configuration pages', () => { const panel = await openPlugins() // Off, the bundle's browser half is not loaded and the row has no configuration to open. - await panel.getByRole('button', { name: '查看 live-client', exact: true }).click() + await panel.getByRole('button', { name: '查看 @fixture/live-client', exact: true }).click() const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' }) await row.waitFor({ timeout: 10_000 }) - expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0) + expect(await panel.getByRole('button', { name: '配置 @fixture/live-client' }).count()).toBe(0) // Switched on, the Host recomposes and the browser half mounts without a // reload; its registration puts the configure control on the row. - await panel.getByRole('switch', { name: '启用 live-client' }).click() - const configure = panel.getByRole('button', { name: '配置 fixture-live-client' }) + await panel.getByRole('switch', { name: '启用 @fixture/live-client' }).click() + const configure = panel.getByRole('button', { name: '配置 @fixture/live-client' }) await configure.waitFor({ timeout: 30_000 }) await configure.click() const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]') await rowPage.waitFor({ timeout: 10_000 }) - expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client') + expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('@fixture/live-client') expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1) const form = rowPage.getByRole('form', { name: '动态插件配置' }) await form.getByLabel('问候语').fill('你好') @@ -309,7 +310,7 @@ describe('web e2e: plugin configuration pages', () => { const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE) - await rowPage.getByRole('button', { name: '返回 live-client' }).click() + await rowPage.getByRole('button', { name: '返回 @fixture/live-client' }).click() await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 }) expect(tripwire.pageErrors).toEqual([]) }, 90_000) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index f2146bc906..373b0ba459 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -19,6 +19,8 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md') const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md') +const EXPORTS_EXPECTED = join(SNAPSHOT_DIR, 'exports.expected.md') +const EXPORTS_EN_EXPECTED = join(SNAPSHOT_DIR, 'exports-en.expected.md') const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url)) const MODE = webSnapshotMode() @@ -30,6 +32,7 @@ describe('web e2e: plugin manager', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] }, }) browser = await chromium.launch() @@ -52,11 +55,32 @@ describe('web e2e: plugin manager', () => { } } + /** Change the UI language through Settings and close the dialog. */ + async function setLanguage(language: 'en' | 'zh'): Promise { + if (await page.locator('html').getAttribute('lang') === language) return + const settings = language === 'en' ? '设置' : 'Settings' + const source = language === 'en' ? '中文' : 'English' + const target = language === 'en' ? 'English' : '中文' + if (await page.getByRole('dialog', { name: settings }).count() === 0) { + await page.getByRole('button', { name: settings, exact: true }).click() + } + await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click() + await page.getByRole('menuitem', { name: target }).click() + const dialog = page.getByRole('dialog', { name: language === 'en' ? 'Settings' : '设置' }) + await dialog.waitFor() + await page.keyboard.press('Escape') + await expect.poll(() => dialog.count()).toBe(0) + } + /** Select the sidebar's Plugins entry and wait for the management page in the main column. */ async function openPluginsPanel() { await closeSettings() await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() const panel = page.locator('[data-plugin-panel]') + await panel.waitFor({ timeout: 10_000 }) + while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) { + await panel.getByRole('button', { name: /^返回/ }).first().click() + } await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) return panel } @@ -69,8 +93,8 @@ describe('web e2e: plugin manager', () => { it('starts with an unavailable selected bundle and lets the user clear its error', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle')) const panel = await openPluginsPanel() - await panel.getByRole('button', { name: '查看 missing-bundle', exact: true }).click() - const toggle = panel.getByRole('switch', { name: '启用 missing-bundle', exact: true }) + await panel.getByRole('button', { name: '查看 @fixture/missing-bundle', exact: true }).click() + const toggle = panel.getByRole('switch', { name: '启用 @fixture/missing-bundle', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('true') expect(await toggle.isDisabled()).toBe(false) await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 }) @@ -93,8 +117,8 @@ describe('web e2e: plugin manager', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list')) const panel = await openPluginsPanel() - await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 }) - const toggle = panel.getByRole('switch', { name: '启用 bundle' }) + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 }) + const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') // The profile's own group holds its one bundle; the installation's optional bundles open the Official // group, followed by the official plugins that registered their configuration, and its other bundles @@ -104,18 +128,47 @@ describe('web e2e: plugin manager', () => { expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4) expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1) // A bundle that is off still shows the rows its patch declares, without switches. - await panel.getByRole('button', { name: '查看 bundle' }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) - expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0) - await panel.getByRole('button', { name: '卸载 bundle' }).waitFor({ timeout: 5_000 }) + expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0) + await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 }) await panel.getByRole('button', { name: '返回插件列表' }).click() - await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0) + await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0) const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('localizes independent exports and falls back per field without activating the plugins', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) + const panel = await openPluginsPanel() + await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() + const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' }) + const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' }) + await search.getByText('文件搜索', { exact: true }).waitFor() + await review.getByText('代码审查', { exact: true }).waitFor() + expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1) + expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1) + await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor() + expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false) + await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) + try { + await setLanguage('en') + await search.getByText('File Search', { exact: true }).waitFor() + expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0) + expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1) + expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0) + await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE) + expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle') + } finally { + await setLanguage('zh') + await closeSettings() + } + await panel.getByRole('button', { name: '返回插件列表' }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + it('updates built-in names and descriptions when the UI language changes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) const panel = await openPluginsPanel() @@ -123,15 +176,16 @@ describe('web e2e: plugin manager', () => { const packageName = panel.locator('[data-plugin-name]') expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1) + const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' }) + await child.getByText('团队工具', { exact: true }).waitFor() + expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) try { - await page.getByRole('button', { name: '设置', exact: true }).click() - await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click() - await page.getByRole('menuitem', { name: 'English' }).click() - await page.getByRole('dialog', { name: 'Settings' }).waitFor() - await page.keyboard.press('Escape') + await setLanguage('en') await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1) + await child.getByText('Team Tools', { exact: true }).waitFor() + expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor() expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1) @@ -140,14 +194,7 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() } } finally { - if (await page.locator('html').getAttribute('lang') === 'en') { - if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) { - await page.getByRole('button', { name: 'Settings', exact: true }).click() - } - await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click() - await page.getByRole('menuitem', { name: '中文' }).click() - await page.getByRole('dialog', { name: '设置' }).waitFor() - } + await setLanguage('zh') await closeSettings() } await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor() @@ -244,7 +291,7 @@ describe('web e2e: plugin manager', () => { it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable')) const panel = await openPluginsPanel() - const toggle = panel.getByRole('switch', { name: '启用 bundle' }) + const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) await toggle.waitFor({ timeout: 20_000 }) const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row') expect(mounted()?.fiber?.state).toBeUndefined() @@ -262,8 +309,8 @@ describe('web e2e: plugin manager', () => { const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE) // The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch. - await panel.getByRole('button', { name: '查看 bundle' }).click() - const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' }) + await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() + const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }) await rowSwitch.waitFor({ timeout: 10_000 }) expect(await rowSwitch.getAttribute('aria-checked')).toBe('true') await rowSwitch.click() @@ -283,7 +330,9 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', + ]) }) }) @@ -291,6 +340,7 @@ describe('web e2e: plugin manager', () => { describe('web e2e: startup-applied plugin management', () => { it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => { const scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] }, }) let browser: Browser | undefined @@ -302,7 +352,7 @@ describe('web e2e: startup-applied plugin management', () => { await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() const panel = page.locator('[data-plugin-panel]') - const toggle = panel.getByRole('switch', { name: '启用 bundle' }) + const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' }) await toggle.waitFor({ timeout: 20_000 }) const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row') const bundles = async () => { @@ -317,9 +367,9 @@ describe('web e2e: startup-applied plugin management', () => { await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 }) expect(mounted()?.fiber?.state).toBeUndefined() // The pack's page lists its rows from their declarations, with no live entry to switch. - await panel.getByRole('button', { name: '查看 bundle' }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) - expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true) + expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true) await panel.getByRole('button', { name: '返回插件列表' }).click() await toggle.click() diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index b396d5f969..d84614d33c 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -37,7 +37,10 @@ describe('web e2e: settings modal and General preferences', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ developerTools: false }) + scaffold = await launchWebScaffold({ + developerTools: false, + extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + }) browser = await chromium.launch() // Chinese browser: the shared page asserts the localized settings surface // the client derives from it (the English default has its own spec below). diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index b75b622d52..7a40d48b79 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-package-manifest": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", "@deepseek-ai/dsh-client-connection": "workspace:^", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 8370696886..cd1ef5cac1 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -5,6 +5,7 @@ * its own settings surface. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' @@ -200,6 +201,20 @@ export class LocaleRuntime { return this.snapshot } + /** + * Resolve package text through the active language's declared fallback chain. + * Plain strings stay verbatim; maps do not consult registered dictionaries. + * @param text - package text whose locale keys are lowercase and include English. + * @returns the first available translation, including an empty string. + */ + resolveText(text: LocalizedText): string { + if (typeof text === 'string') return text + return this.fallbackChain(this.snapshot.active).reduceRight( + (resolved, locale) => text[localeKey(locale)] ?? resolved, + text.en, + ) + } + /** * LocaleFace getSnapshot: the current snapshot (carries `revision`; stable * reference between changes, uSES-safe). diff --git a/packages/client/locale/tests/localized-text.client.spec.ts b/packages/client/locale/tests/localized-text.client.spec.ts new file mode 100644 index 0000000000..a567962ae7 --- /dev/null +++ b/packages/client/locale/tests/localized-text.client.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '../src/client/index.ts' + +function make() { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + return { ctx, locale: new LocaleRuntime(ctx) } +} + +describe('package text resolution', () => { + it('keeps strings verbatim without common lookup or interpolation', () => { + const { ctx, locale } = make() + ctx.effect(() => locale.register('common', 'zh', { retry: '重试' }), 'test: common dictionary') + locale.setLocale('zh') + + expect(locale.resolveText('retry')).toBe('retry') + expect(locale.resolveText('Hello, {name}')).toBe('Hello, {name}') + expect(locale.resolveText('')).toBe('') + expect(locale.resolveText({ en: 'retry' })).toBe('retry') + }) + + it('resolves each field in the current locale without registering map languages', () => { + const { locale } = make() + const title = { en: 'Tools', zh: '工具', ja: 'ツール' } + locale.setLocale('zh') + const snapshot = locale.getSnapshot() + + expect(locale.resolveText(title)).toBe('工具') + expect(locale.resolveText({ en: 'English description' })).toBe('English description') + expect(locale.resolveText({ en: 'Fallback', zh: '' })).toBe('') + expect(locale.getSnapshot()).toBe(snapshot) + expect(snapshot.locales.map(item => item.id)).toEqual(['zh', 'en']) + + locale.setLocale('en') + expect(locale.resolveText(title)).toBe('Tools') + }) + + it('walks registered regional fallbacks using lowercase map keys', () => { + const { ctx, locale } = make() + ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: French') + ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French') + locale.setLocale('FR-ca') + + expect(locale.resolveText({ en: 'Tools', fr: 'Outils', 'fr-ca': 'Outils Québec' })).toBe('Outils Québec') + expect(locale.resolveText({ en: 'Tools', fr: 'Outils' })).toBe('Outils') + expect(locale.resolveText({ en: 'Tools', zh: '工具' })).toBe('Tools') + }) + + it('uses English across an unloaded fallback and follows its replacement', () => { + const { ctx, locale } = make() + const removeFrench = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }) + ctx.effect(() => removeFrench, 'test: French') + ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French') + locale.setLocale('fr-CA') + const text = { en: 'Tools', fr: 'Outils' } + + expect(locale.resolveText(text)).toBe('Outils') + removeFrench() + expect(locale.resolveText(text)).toBe('Tools') + ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: replacement French') + expect(locale.resolveText(text)).toBe('Outils') + }) +}) diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 8737cc0c01..0d4fa48c3f 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/schemastery" }, diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index a5f08aa391..cc7db22e85 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-plugin-manager": "workspace:^", + "@deepseek-ai/dsh-package-manifest": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a97e31e8ca..5235d53cd0 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -26,10 +26,10 @@ import { rowConfigKey, type OfficialItem } from './config-ledger.ts' import type { PluginManagerLocaleKey } from './locales.ts' import { isInstallPending, offeredRegistries, rowKey, - type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, + type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView, type PluginManagerFace, type RegistryChoice, } from './manager-store.ts' -import { managementText, noticeText, packageText, registryText, type Translate } from './presentation.ts' +import { managementText, noticeText, packageText, registryText, rowText, type Translate } from './presentation.ts' import type {} from './slot-contract.ts' import css from './PluginManagerPage.module.css' @@ -42,6 +42,7 @@ export type PluginManagerPageProps = /** The page's slot renderer, narrowed to the configuration slots. */ type RenderConfig = PluginManagerPageProps['renderSlot'] +type ResolveText = PluginManagerFace['resolveText'] /** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */ type View = @@ -147,8 +148,9 @@ function packageArtwork(name: string): ReactNode { } /** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ -function RowSwitch({ row, t, busy, onChange }: { +function RowSwitch({ row, title, t, busy, onChange }: { readonly row: PackageRow + readonly title: string readonly t: Translate readonly busy: boolean readonly onChange: (enabled: boolean) => void @@ -157,7 +159,7 @@ function RowSwitch({ row, t, busy, onChange }: { return ( {t('metadataError', { error })}

+} + /** * A pack's rows as a list in the order the pack declares them: a state dot, * the row id, one line saying its state, a configure control for a row that * registered a page, and, when the pack is on, a switch. A pack like base * carries close to a hundred rows, so a long list gets a filter. */ -function RowsSection({ rows, t, toggle, configure }: { +function RowsSection({ rows, t, resolveText, toggle, configure }: { readonly rows: readonly PackageRow[] readonly t: Translate + readonly resolveText: ResolveText readonly toggle?: RowToggles | undefined readonly configure?: RowConfigure | undefined }): ReactNode { const [filter, setFilter] = useState('') const query = filter.trim().toLowerCase() - const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query)) + const localized = rows.map(row => ({ row, ...rowText(row, resolveText) })) + const shown = query === '' ? localized : localized.filter(({ row, title, description }) => + [title, description, row.rowId, row.moduleName].some(value => value?.toLowerCase().includes(query))) return (
@@ -216,7 +226,7 @@ function RowsSection({ rows, t, toggle, configure }: { ? null : (
    - {shown.map(row => ( + {shown.map(({ row, title, description }) => (
  • {configure?.has(row) === true ? ( - ) - : {row.rowId}} - {row.moduleName} + : {title}} + {description === undefined ? null : {description}} + {title === row.rowId ? null : {row.rowId}} + {title === row.moduleName ? null : {row.moduleName}}
@@ -242,8 +254,12 @@ function RowsSection({ rows, t, toggle, configure }: { {toggle === undefined ? null - : { toggle.onSetEnabled(row, enabled) }} />} + : { toggle.onSetEnabled(row, enabled) }} + />} + ))} @@ -290,15 +306,16 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: { readonly description: ReactNode readonly end?: ReactNode }): ReactNode { + const descriptionId = useId() return (
- + {tags}
- {description === undefined ? null : {description}} + {description === undefined ? null : {description}}
{end === undefined ? null :
{end}
}
@@ -328,15 +345,16 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { } /** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ -function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { +function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnabled }: { readonly pkg: PackageView readonly t: Translate + readonly resolveText: ResolveText readonly busy: boolean readonly highlighted: boolean readonly onOpen: () => void readonly onSetEnabled: (enabled: boolean) => void }): ReactNode { - const { title, description, beta } = packageText(pkg, t) + const { title, description, beta } = packageText(pkg, resolveText) const status = packageStatus(pkg) return (
  • } /> +
  • ) } @@ -404,28 +423,32 @@ function ItemDetail({ item, t, onBack, renderSlot }: { } /** - * A row's configuration page: the crumb back to its bundle's page, the row id - * over the module it names and the entry's one-liner, and the form the entry renders. + * A row's configuration page keeps its technical identity beside local package + * text and the form supplied by its configuration entry. */ -function RowDetail({ pkg, row, t, onBack, renderSlot }: { +function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: { readonly pkg: PackageView readonly row: PackageRow readonly t: Translate + readonly resolveText: ResolveText readonly onBack: () => void readonly renderSlot: RenderConfig }): ReactNode { - const { title } = packageText(pkg, t) + const { title } = packageText(pkg, resolveText) + const { title: rowTitle, description } = rowText(row, resolveText) const key = rowConfigKey(pkg.name, row.rowId) return (
    } />
    -

    {row.rowId}

    +

    {rowTitle}

    + {rowTitle === row.rowId ? null :

    {row.rowId}

    }

    {row.moduleName}

    -

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

    +

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

    +
    {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
    @@ -442,11 +465,12 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { * itself; and its rows with their switches and configure controls. */ function PackageDetail({ - pkg, t, busy, rowBusy, configured, configure, renderSlot, + pkg, t, resolveText, busy, rowBusy, configured, configure, renderSlot, onBack, onSetEnabled, onUninstall, onSetRowEnabled, }: { readonly pkg: PackageView readonly t: Translate + readonly resolveText: ResolveText readonly busy: boolean /** Whether a row has a write in flight. */ readonly rowBusy: (row: PackageRow) => boolean @@ -459,7 +483,7 @@ function PackageDetail({ readonly onUninstall: () => void readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void }): ReactNode { - const { title, description, beta } = packageText(pkg, t) + const { title, description, beta } = packageText(pkg, resolveText) const status = packageStatus(pkg) return (
    @@ -497,8 +521,9 @@ function PackageDetail({ {status === 'problem' ? {t('statusProblem')} : null}

    {pkg.name}

    -

    {description ?? t('noDescription')}

    + {description === undefined ? null :

    {description}

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

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

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

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

    }
    @@ -512,6 +537,7 @@ function PackageDetail({ busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined} configure={configure} /> @@ -1012,13 +1038,12 @@ function InstallDialog({ } /** The confirmation an uninstall waits on. */ -function ConfirmDialog({ confirm, t, onConfirm, onCancel }: { - readonly confirm: ConfirmState +function ConfirmDialog({ name, t, onConfirm, onCancel }: { + readonly name: string readonly t: Translate readonly onConfirm: () => void readonly onCancel: () => void }): ReactNode { - const { title: name } = packageText({ name: confirm.packageName }, t) return ( snapshot) const ledger = props.useConfigLedger(snapshot => snapshot) // What is open; a package that leaves the list (uninstalled) drops back to the cards. @@ -1082,6 +1107,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { key={pkg.name} pkg={pkg} t={t} + resolveText={resolveText} busy={state.busy.includes(pkg.name)} highlighted={state.highlight === pkg.name} onOpen={() => { setView({ kind: 'package', name: pkg.name }) }} @@ -1165,6 +1191,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { pkg={openPkg} row={openRow} t={t} + resolveText={resolveText} renderSlot={renderSlot} onBack={() => { setView({ kind: 'package', name: openPkg.name }) }} /> @@ -1175,6 +1202,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} configured={ledger.bundles.has(openPkg.name)} @@ -1231,7 +1259,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ? null : ( pkg.name === state.confirm?.packageName) ?? { name: state.confirm.packageName }, + resolveText, + ).title} t={t} onConfirm={props.confirm} onCancel={props.cancelConfirm} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 1e2c9a60b1..f3394b69cc 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void { name: 'main', key: PANEL_ID, locale: NS, - inject: () => controller.inject(configLedger), + inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)), children: { 'plugins.item': { kind: 'list', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 74d367aec6..30116aa2bf 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -1,4 +1,4 @@ -/** Plugin management copy and display names of shipped global rows. */ +/** Plugin management interface copy. */ /** Simplified Chinese dictionary and key source of truth. */ export const zh = { @@ -16,15 +16,11 @@ export const zh = { overriddenNotice: '{name} 已保存,但被更高优先级的配置覆盖,当前未生效', bundlesTitle: '已安装', officialTitle: '官方', - builtinAgentTeamTitle: '智能体团队', - builtinAgentTeamDescription: '启用团队协作、团队工具、成员列表和共享任务看板。', - builtinAutoReviewTitle: '自动授权审查', - builtinAutoReviewDescription: '提供自动审查权限模式,由模型在每次工具调用前判断是否授权。', statusProblem: '异常', statusBeta: 'Beta', reasonLabel: '原因', + metadataError: '包元信息错误:{error}', versionTag: 'v{version}', - noDescription: '暂无描述。', partsLabel: '包含的组件', partsEmpty: '这个插件包不包含任何组件。', partsCountTotal: '共 {count} 个', @@ -204,15 +200,11 @@ export const en = { overriddenNotice: '{name} was saved, but a higher-priority configuration overrides it, so it is not in effect', bundlesTitle: 'Installed', officialTitle: 'Official', - builtinAgentTeamTitle: 'Agent Teams', - builtinAgentTeamDescription: 'Enable team collaboration, team tools, the member roster, and the shared task board.', - builtinAutoReviewTitle: 'Auto Authorization Review', - builtinAutoReviewDescription: 'Add an Auto review permission mode that uses the model to assess authorization before each tool call.', statusProblem: 'Problem', statusBeta: 'Beta', reasonLabel: 'Reason', + metadataError: 'Package metadata error: {error}', versionTag: 'v{version}', - noDescription: 'No description.', partsLabel: 'Components', partsEmpty: 'This plugin pack contains no components.', partsCountTotal: '{count} total', 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 533893caa8..2113612733 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -27,6 +27,7 @@ import type { import { REGISTRY_URL } from '@deepseek-ai/dsh-plugin-manager/registry' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { LocalizedText, PackageMeta } from '@deepseek-ai/dsh-package-manifest' import type { ConfigLedger } from './config-ledger.ts' import { shortName } from './presentation.ts' @@ -59,6 +60,8 @@ export interface PackageRow { readonly rowId: string /** The module the row names. */ readonly moduleName: string + /** Local package display text and metadata diagnostics supplied by the Host. */ + readonly meta?: PackageMeta /** Whether the entry runs; false for a row without a live entry. */ readonly enabled: boolean /** The entry's fiber phase, null without a live fiber. */ @@ -72,6 +75,8 @@ export interface PackageView { readonly name: string readonly version?: string readonly description?: string + /** Local package display text and metadata diagnostics supplied by the Host. */ + readonly meta?: PackageMeta /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ readonly installed: boolean /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ @@ -220,6 +225,8 @@ export interface PluginManagerState { /** The registration-side face the tab's slot entry injects. */ export interface PluginManagerFace { + /** Resolve local package text in the current Client locale at render time. */ + resolveText: (text: LocalizedText) => string hooks: { /** Tab snapshot bound by the renderer as usePluginManager. */ pluginManager: SnapshotStore @@ -325,6 +332,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): moduleName: row.moduleName, enabled: live?.enabled ?? false, phase: live?.fiberPhase ?? null, + ...row.meta === undefined ? {} : { meta: row.meta }, ...row.entryId === undefined ? {} : { entryId: row.entryId }, ...live?.readOnlyReason === undefined ? {} : { readOnlyReason: live.readOnlyReason }, } @@ -337,6 +345,7 @@ export function packageView(bundle: BundleInfo, plugins: readonly PluginInfo[]): rows, ...bundle.version === undefined ? {} : { version: bundle.version }, ...bundle.description === undefined ? {} : { description: bundle.description }, + ...bundle.meta === undefined ? {} : { meta: bundle.meta }, ...bundle.readOnlyReason === undefined ? {} : { readOnlyReason: bundle.readOnlyReason }, ...bundle.error === undefined ? {} : { error: bundle.error }, } @@ -431,10 +440,12 @@ export class PluginManagerController { /** * Build the face the tab's slot registration injects. * @param configLedger - the projection of the plugins carrying configuration, bound beside the tab's own state. + * @param resolveText - render-time package text resolution supplied by the locale service. * @returns the tab's snapshot sources and its actions. */ - inject(configLedger: HostObservable): PluginManagerFace { + inject(configLedger: HostObservable, resolveText: PluginManagerFace['resolveText']): PluginManagerFace { return { + resolveText, hooks: { pluginManager: this.store, configLedger }, ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() }, refresh: () => { void this.load() }, diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index d3446a13db..c76633ff97 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -3,19 +3,15 @@ import type { ManagementError, Registry } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PluginManagerLocaleKey } from './locales.ts' -import type { FailedAction, ManagerNotice, PackageView } from './manager-store.ts' +import type { FailedAction, ManagerNotice, PackageRow, PackageView, PluginManagerFace } from './manager-store.ts' /** The translate seat of the manager's dictionary. */ export type Translate = PropsLocale<'pluginManager'>['t'] -/** The official packages with copy of their own, and whether each is a beta feature the page tags as such. */ -const BUILTIN_COPY = new Map([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', { - title: 'builtinAgentTeamTitle', description: 'builtinAgentTeamDescription', beta: true, - }], - ['@deepseek-ai/dsh-experimental-auto-review', { - title: 'builtinAutoReviewTitle', description: 'builtinAutoReviewDescription', beta: true, - }], +/** Official packages whose release status the page identifies as beta. */ +const BETA_PACKAGES = new Set([ + '@deepseek-ai/dsh-experimental-agent-team-profile', + '@deepseek-ai/dsh-experimental-auto-review', ]) /** The registries with a name of their own, by host. */ @@ -98,18 +94,34 @@ export function shortName(name: string): string { } /** - * Localize known official packages by exact npm name at render time. - * @param pkg - original package identity and optional metadata description. - * @param t - the manager's current translate function. - * @returns localized copy and whether the package is a beta feature, or the package's short name and original description. + * Resolve installed package metadata without changing its technical identity. + * @param pkg - package identity and local metadata. + * @param resolveText - current-locale package text resolver. + * @returns localized copy with a technical-name fallback and the independent beta status. */ export function packageText( - pkg: Pick, t: Translate, + pkg: Pick, resolveText: PluginManagerFace['resolveText'], ): { title: string; description: string | undefined; beta: boolean } { - const keys = BUILTIN_COPY.get(pkg.name) - return keys === undefined - ? { title: shortName(pkg.name), description: pkg.description, beta: false } - : { title: t(keys.title), description: t(keys.description), beta: keys.beta } + return { + title: pkg.meta?.title === undefined ? pkg.name : resolveText(pkg.meta.title), + description: pkg.meta?.description === undefined ? undefined : resolveText(pkg.meta.description) || undefined, + beta: BETA_PACKAGES.has(pkg.name), + } +} + +/** + * Resolve a bundle row's plugin metadata, using its full module specifier as the final title fallback. + * @param row - row identity and local metadata. + * @param resolveText - current-locale package text resolver. + * @returns the row's display title and optional description. + */ +export function rowText( + row: Pick, resolveText: PluginManagerFace['resolveText'], +): { title: string; description: string | undefined } { + return { + title: row.meta?.title === undefined ? row.moduleName : resolveText(row.meta.title), + description: row.meta?.description === undefined ? undefined : resolveText(row.meta.description) || undefined, + } } /** 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 2fd0b41d40..89a9b52115 100644 --- a/packages/client/ui-plugin-manager/src/client/slot-contract.ts +++ b/packages/client/ui-plugin-manager/src/client/slot-contract.ts @@ -5,9 +5,10 @@ * that row. A registrant merges this contract with `import type` and registers * through `ctx.slots`; it never imports this package at runtime. * - * Every entry is rendered in two views the owner asks for: `summary` for the - * one-liner the page places under the title, `page` for the form with its own - * save control. The page draws the title, the icon, and the crumb itself. + * Entries accept `page` for the form with its own save control and `summary` + * for an official card's one-liner or a row's missing-description fallback. + * Bundle configuration renders only `page`. The page draws the title, icon, + * and crumb itself. */ import type {} from '@deepseek-ai/dsh-client-ui-slots' @@ -40,7 +41,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * The configuration of one row a bundle declares, keyed by * `#` with the row id as the bundle's patch declares * it: the row on the bundle's page gains a configure control that opens - * the entry's page, headed by the row id and the entry's summary. + * the entry's page, headed by the plugin's display title and description. + * An absent description falls back to the entry's `view: 'summary'`. */ 'plugins.row.config': { kind: 'keyed'; scope: 'root'; owner: PluginConfigViewProps } } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index d3825fc6a0..60194747fe 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { Context, Service } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -17,6 +17,7 @@ afterEach(cleanup) async function bench() { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) @@ -83,6 +84,11 @@ describe('ui-plugin-manager browser plugin', () => { expect(b.slots.spec('plugins.bundle.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) expect(b.slots.spec('plugins.row.config')).toMatchObject({ kind: 'keyed', scope: 'root' }) const face = (entry.inject as unknown as () => PluginManagerFace)() + const text = { en: 'Local tools', zh: '本地工具' } + expect(face.resolveText(text)).toBe('本地工具') + b.locale.setLocale('en') + expect(face.resolveText(text)).toBe('Local tools') + b.locale.setLocale('zh') expect(face.hooks.configLedger.getSnapshot()).toEqual({ items: [], bundles: new Set(), rows: new Set() }) // A Host change before the first render is not a reason to read. b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) 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 22e5d51828..0737596fb5 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -69,6 +71,11 @@ type SlotBodies = Record ReactNode> const NO_CONFIG: ConfigLedger = { items: [], bundles: new Set(), rows: new Set() } function renderTab(state: Partial = {}, config: Partial = {}, bodies: SlotBodies = {}) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const locale = new LocaleRuntime(ctx) + locale.setLocale('en') + const resolveText: PluginManagerPageProps['resolveText'] = text => locale.resolveText(text) const store = createSnapshotStore({ ...READY, ...state }) const ledger = createSnapshotStore({ ...NO_CONFIG, ...config }) const actions = { @@ -96,6 +103,7 @@ function renderTab(state: Partial = {}, config: Partial = {}, config: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }, - setLanguage: (dict: typeof en) => { rerender() }, + setLanguage: (dict: typeof en) => { + locale.setLocale(dict === zh ? 'zh' : 'en') + rerender() + }, } } @@ -133,7 +144,7 @@ describe('PluginManagerPage', () => { it('keeps the read failure and its retry visible while a detail page is open', () => { const { actions, set } = renderTab({ packages: [pkg()] }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) expect(screen.queryByRole('alert')).toBeNull() set({ status: 'error' }) expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull() @@ -146,7 +157,7 @@ describe('PluginManagerPage', () => { it('lists the installed bundles as cards, the installation\'s offered ones as official, and tags a problem the Host reports', () => { const { actions } = renderTab({ packages: [ - pkg({ description: 'A sidebar.' }), + pkg({ meta: { description: { en: 'A sidebar.' } } }), pkg({ name: 'dsh-broken', enabled: false, error: { code: 'not-bundle' } }), pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }), pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }), @@ -168,15 +179,14 @@ describe('PluginManagerPage', () => { expect(screen.getByRole('heading', { name: en.officialTitle })).toBeTruthy() expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5']) expect(screen.getAllByText(en.statusBeta)).toHaveLength(1) - // A scoped name reads without its scope and harness prefix. - expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false) + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', '@acme/dsh-tool') })).toHaveProperty('disabled', false) expect(screen.getByText('A sidebar.')).toBeTruthy() expect(screen.getAllByText(en.statusProblem)).toHaveLength(2) // The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is. - fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })) expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false) - expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'broken') })).toHaveProperty('disabled', true) - const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'protected') }) + expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-broken') })).toHaveProperty('disabled', true) + const locked = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-protected') }) expect(locked).toHaveProperty('disabled', true) expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired) }) @@ -215,52 +225,258 @@ describe('PluginManagerPage', () => { }) it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => { + const title = 'Agent Teams' const { actions } = renderTab({ - packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, enabled: false })], + packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', meta: { title }, installed: false, optional: true, enabled: false })], }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', en.builtinAgentTeamTitle) })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement expect(within(detail).getByText(en.statusBeta)).toBeTruthy() - expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', en.builtinAgentTeamTitle) })).toBeNull() - fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', en.builtinAgentTeamTitle) })) + expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', title) })).toBeNull() + fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', title) })) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) }) it.each([ - ['agent-team-profile', 'builtinAgentTeamTitle', 'builtinAgentTeamDescription'], - ['auto-review', 'builtinAutoReviewTitle', 'builtinAutoReviewDescription'], - ] as const)('localizes %s across cards, details, switches, and uninstall confirmation', (suffix, titleKey, descriptionKey) => { - const name = `@deepseek-ai/dsh-experimental-${suffix}` - const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, description: 'Original metadata.' })] }) + '@deepseek-ai/dsh-experimental-agent-team-profile', + '@deepseek-ai/dsh-experimental-auto-review', + '@acme/dsh-local-tools', + ])('localizes Host metadata for %s across cards, details, switches, and uninstall confirmation', (name) => { + const meta = { + title: { en: 'Installed tools', zh: '已安装工具' }, + description: { en: 'Tools on this host.', zh: '本机工具。' }, + } + const title = (dict: typeof en): string => dict === zh ? meta.title.zh : meta.title.en + const description = (dict: typeof en): string => dict === zh ? meta.description.zh : meta.description.en + const { actions, set, setLanguage } = renderTab({ packages: [pkg({ name, meta, description: 'Original metadata.' })] }) const assertCard = (dict: typeof en) => { - expect(screen.getByRole('button', { name: dict.openDetail.replace('{name}', dict[titleKey]) }).textContent).toBe(dict[titleKey]) - expect(screen.getByText(dict[descriptionKey])).toBeTruthy() - expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy() + const card = screen.getByRole('button', { name: dict.openDetail.replace('{name}', title(dict)) }) + expect(card.textContent).toBe(title(dict)) + expect(screen.getByText(description(dict))).toBeTruthy() + expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description(dict)) + expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy() expect(screen.queryByText('Original metadata.')).toBeNull() } assertCard(en) setLanguage(zh) assertCard(zh) - fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', zh[titleKey]) })) + fireEvent.click(screen.getByRole('switch', { name: zh.enableToggle.replace('{name}', title(zh)) })) expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith(name, false) - fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', zh[titleKey]) })) + fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', title(zh)) })) for (const dict of [zh, en]) { setLanguage(dict) - expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(dict[titleKey]) - expect(screen.getByText(dict[descriptionKey])).toBeTruthy() + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title(dict)) + expect(screen.getByText(description(dict))).toBeTruthy() expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) - expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', dict[titleKey]) })).toBeTruthy() - expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', dict[titleKey]) })).toBeTruthy() + expect(screen.getByRole('switch', { name: dict.enableToggle.replace('{name}', title(dict)) })).toBeTruthy() + expect(screen.getByRole('button', { name: dict.uninstallLabel.replace('{name}', title(dict)) })).toBeTruthy() } - fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', en[titleKey]) })) + fireEvent.click(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', title(en)) })) expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith(name) set({ confirm: { action: 'uninstall', packageName: name } }) for (const dict of [en, zh]) { setLanguage(dict) - expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', dict[titleKey]) })).toBeTruthy() + expect(screen.getByRole('dialog', { name: dict.confirmUninstallTitle.replace('{name}', title(dict)) })).toBeTruthy() } }) + it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => { + const error = 'locale/zh.json: invalid title' + const { actions, set, setLanguage } = renderTab({ + packages: [pkg({ enabled: false, description: 'Legacy description.', meta: { error } })], + }) + expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy() + expect(screen.queryByText('Legacy description.')).toBeNull() + const enable = screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') }) + expect(enable).toHaveProperty('disabled', false) + fireEvent.click(enable) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar', true) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy() + const uninstall = screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') }) + expect(uninstall).toHaveProperty('disabled', false) + fireEvent.click(uninstall) + expect(actions.uninstall).toHaveBeenCalledExactlyOnceWith('dsh-better-sidebar') + + setLanguage(zh) + expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() + set({ packages: [pkg({ meta: { title: 'Literal title', description: { en: 'English fallback.' } } })] }) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Literal title') + expect(screen.getByText('English fallback.')).toBeTruthy() + expect(document.querySelector('[data-package-meta-error]')).toBeNull() + }) + + it.each<{ + field: string + meta: NonNullable + englishTitle: string + chineseTitle: string + englishDescription?: string + chineseDescription: string + }>([ + { + field: 'title', + meta: { title: { en: '@acme/dsh-sidebar', zh: '侧栏套件' }, description: 'Package description.' }, + englishTitle: '@acme/dsh-sidebar', chineseTitle: '侧栏套件', + englishDescription: 'Package description.', chineseDescription: 'Package description.', + }, + { + field: 'description', + meta: { title: { en: 'English title' }, description: { en: '', zh: '中文套件说明。' } }, + englishTitle: 'English title', chineseTitle: 'English title', chineseDescription: '中文套件说明。', + }, + ])('resolves bundle $field independently and hides empty descriptions on cards and details', ({ meta, englishTitle, chineseTitle, englishDescription, chineseDescription }) => { + const { setLanguage } = renderTab({ packages: [pkg({ name: '@acme/dsh-sidebar', meta })] }) + const languages = [ + { dict: en, title: englishTitle, description: englishDescription }, + { dict: zh, title: chineseTitle, description: chineseDescription }, + { dict: en, title: englishTitle, description: englishDescription }, + ] + for (const { dict, title, description } of languages) { + setLanguage(dict) + const card = screen.getByRole('button', { name: dict.openDetail.replace('{name}', title) }) + expect(card.textContent).toBe(title) + if (description === undefined) { + expect(card.getAttribute('aria-describedby')).toBeNull() + expect(screen.queryByText(chineseDescription)).toBeNull() + } else { + expect(screen.getByText(description)).toBeTruthy() + expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description) + } + } + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', englishTitle) })) + for (const { dict, title, description } of languages) { + setLanguage(dict) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(title) + if (description === undefined) { + expect(screen.queryByText(chineseDescription)).toBeNull() + expect(screen.queryByText(dict === zh ? '暂无描述。' : 'No description.')).toBeNull() + } else { + expect(screen.getByText(description)).toBeTruthy() + } + } + }) + + it('resolves row fields independently from bundle metadata and preserves subpath specifiers', () => { + const rows = [ + row({ moduleName: '@acme/dsh-sidebar/navigation', meta: { description: { en: 'Navigation description.' } } }), + row({ + rowId: 'theme', entryId: 'include:theme' as PluginEntryId, moduleName: '@acme/dsh-theme/client', + meta: { title: { en: '@acme/dsh-theme', zh: '主题插件' }, description: { en: '', zh: '中文主题说明。' } }, + }), + ] + const { actions, setLanguage } = renderTab( + { packages: [pkg({ meta: { title: 'Bundle title', description: 'Bundle description.' }, rows })] }, + { rows: new Set(['dsh-better-sidebar#theme']) }, + { 'plugins.row.config:dsh-better-sidebar#theme': view => view === 'page' ?
    : null }, + ) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Bundle title') })) + expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })).toBeTruthy() + expect(screen.getByRole('switch', { name: en.partToggle.replace('{name}', '@acme/dsh-theme') })).toBeTruthy() + expect(screen.queryByText('中文主题说明。')).toBeNull() + setLanguage(zh) + const navigation = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement + expect(within(navigation).getByText('Navigation description.')).toBeTruthy() + expect(within(navigation).queryByText('Bundle title')).toBeNull() + expect(within(navigation).queryByText('Bundle description.')).toBeNull() + fireEvent.click(within(navigation).getByRole('switch', { name: zh.partToggle.replace('{name}', '@acme/dsh-sidebar/navigation') })) + expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false) + expect(screen.getByText('中文主题说明。')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '主题插件') })) + expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#theme') + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('主题插件') + expect(screen.getByText('中文主题说明。')).toBeTruthy() + setLanguage(en) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('@acme/dsh-theme') + expect(screen.getByText('@acme/dsh-theme/client')).toBeTruthy() + expect(screen.getByText('theme')).toBeTruthy() + expect(screen.queryByText('中文主题说明。')).toBeNull() + expect(screen.getByRole('form', { name: 'theme settings' })).toBeTruthy() + }) + + it('shows a row id only once when it is the localized title in lists and configuration pages', () => { + const moduleName = '@acme/dsh-sidebar/navigation' + const { setLanguage } = renderTab( + { packages: [pkg({ rows: [row({ moduleName, meta: { title: { en: 'Sidebar component', zh: 'sidebar' } } })] })] }, + { rows: new Set(['dsh-better-sidebar#sidebar']) }, + { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'page' ? : null }, + ) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + const listed = document.querySelector('[data-plugin-row="include:sidebar"]') as HTMLElement + expect(within(listed).getByText('Sidebar component')).toBeTruthy() + expect(within(listed).getByText('sidebar', { selector: 'code' })).toBeTruthy() + setLanguage(zh) + expect(within(listed).getAllByText('sidebar')).toHaveLength(1) + expect(within(listed).queryByText('sidebar', { selector: 'code' })).toBeNull() + expect(within(listed).getByText(moduleName, { selector: 'code' })).toBeTruthy() + + fireEvent.click(within(listed).getByRole('button', { name: zh.configureRow.replace('{name}', 'sidebar') })) + const detail = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement + expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('sidebar') + expect(within(detail).getAllByText('sidebar')).toHaveLength(1) + expect(within(detail).queryByText('sidebar', { selector: 'code' })).toBeNull() + expect(within(detail).getByText(moduleName, { selector: 'code' })).toBeTruthy() + expect(within(detail).getByRole('form', { name: 'sidebar settings' })).toBeTruthy() + setLanguage(en) + expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('Sidebar component') + expect(within(detail).getByText('sidebar', { selector: 'code' })).toBeTruthy() + }) + + it('translates row text, searches current copy and technical identities, and keeps configuration keys unchanged', () => { + const error = 'locale/zh.json: invalid description' + const localized = row({ + moduleName: '@acme/dsh-sidebar-widget', + meta: { + title: { en: 'Sidebar component', zh: '导航组件' }, + description: { en: 'Sidebar navigation', zh: '侧边导航' }, + error, + }, + }) + const rows = [localized, ...Array.from({ length: 10 }, (_, index) => row({ + rowId: `extra-${String(index)}`, entryId: `include:extra-${String(index)}` as PluginEntryId, moduleName: '@acme/other', + }))] + const { actions, setLanguage } = renderTab( + { packages: [pkg({ meta: { title: { en: 'Personal tools', zh: '个人工具' } }, rows })] }, + { rows: new Set(['dsh-better-sidebar#sidebar']) }, + { 'plugins.row.config:dsh-better-sidebar#sidebar': view => view === 'summary' ? 'Config summary' : }, + ) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Personal tools') })) + setLanguage(zh) + const search = screen.getByRole('searchbox', { name: zh.partsFilter }) + for (const query of ['导航组件', '侧边导航', 'sidebar', '@acme/dsh-sidebar-widget']) { + fireEvent.change(search, { target: { value: query } }) + expect(document.querySelectorAll('[data-plugin-row]')).toHaveLength(1) + expect(screen.getByRole('button', { name: zh.configureRow.replace('{name}', '导航组件') })).toBeTruthy() + } + expect(screen.getByText('sidebar')).toBeTruthy() + expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy() + expect(screen.getByText('侧边导航')).toBeTruthy() + expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() + const toggle = screen.getByRole('switch', { name: zh.partToggle.replace('{name}', '导航组件') }) + expect(toggle).toHaveProperty('disabled', false) + fireEvent.click(toggle) + expect(actions.setRowEnabled).toHaveBeenCalledExactlyOnceWith('include:sidebar', false) + + fireEvent.change(search, { target: { value: 'Sidebar component' } }) + expect(screen.getByText(zh.partsFilterEmpty)).toBeTruthy() + setLanguage(en) + expect(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'Sidebar component') })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'Sidebar component') })) + expect(document.querySelector('[data-plugin-row-detail]')?.getAttribute('data-plugin-row-detail')).toBe('dsh-better-sidebar#sidebar') + expect(screen.getByRole('form', { name: 'row settings' })).toBeTruthy() + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('Sidebar component') + expect(screen.getByText('Sidebar navigation')).toBeTruthy() + expect(screen.queryByText('Config summary')).toBeNull() + setLanguage(zh) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('导航组件') + expect(screen.getByText('侧边导航')).toBeTruthy() + expect(screen.getByText('sidebar')).toBeTruthy() + expect(screen.getByText('@acme/dsh-sidebar-widget')).toBeTruthy() + expect(screen.getByText(zh.metadataError.replace('{error}', error))).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: zh.backToPackage.replace('{name}', '个人工具') })) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('个人工具') + }) + describe('configuration pages', () => { const bodies: SlotBodies = { 'plugins.item:bash': view => view === 'summary' ? 'Limits every command.' : , @@ -306,35 +522,36 @@ describe('PluginManagerPage', () => { it('renders a bundle\'s own configuration on its page, and no configure control on a row without one', () => { renderTab({ packages: [pkg({ rows: [row()] })] }, { bundles: new Set(['dsh-better-sidebar']) }, bodies) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement expect(within(detail).getByRole('form', { name: 'sidebar form' })).toBeTruthy() - expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })).toBeNull() + expect(within(detail).queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })).toBeNull() }) it('opens a row\'s configuration page from its configure control and leads back to the bundle', () => { const theme = row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId }) renderTab({ packages: [pkg({ rows: [row(), theme] })] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }, bodies) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) - expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'theme') })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expect(screen.queryByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar/theme') })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.configureRow.replace('{name}', 'dsh-better-sidebar') })) const page = document.querySelector('[data-plugin-row-detail="dsh-better-sidebar#sidebar"]') as HTMLElement - expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('sidebar') - expect(within(page).getByText('dsh-better-sidebar')).toBeTruthy() + expect(within(page).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar') + expect(within(page).getByText('dsh-better-sidebar', { selector: 'code' })).toBeTruthy() + expect(within(page).getByText('sidebar', { selector: 'code' })).toBeTruthy() expect(within(page).getByText('The sidebar row.')).toBeTruthy() expect(within(page).getByRole('form', { name: 'row form' })).toBeTruthy() - fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'better-sidebar') })) + fireEvent.click(within(page).getByRole('button', { name: en.backToPackage.replace('{name}', 'dsh-better-sidebar') })) expect(document.querySelector('[data-plugin-row-detail]')).toBeNull() expect(document.querySelector('[data-plugin-detail="dsh-better-sidebar"]')).toBeTruthy() }) }) - it('preserves metadata for another scope with the same short name', () => { + it('preserves the full package name and description for a third-party scope', () => { const name = '@acme/dsh-experimental-agent-team-profile' - const { setLanguage } = renderTab({ packages: [pkg({ name, description: 'Third-party description.' })] }) + const { setLanguage } = renderTab({ packages: [pkg({ name, meta: { description: { en: 'Third-party description.' } } })] }) setLanguage(zh) - fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', 'experimental-agent-team-profile') })) - expect(screen.getByRole('heading', { level: 3 }).textContent).toBe('experimental-agent-team-profile') + fireEvent.click(screen.getByRole('button', { name: zh.openDetail.replace('{name}', name) })) + expect(screen.getByRole('heading', { level: 3 }).textContent).toBe(name) expect(screen.getByText('Third-party description.')).toBeTruthy() expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name) }) @@ -357,16 +574,15 @@ describe('PluginManagerPage', () => { it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => { const { actions, set } = renderTab({ packages: [pkg({ - description: 'A sidebar.', + meta: { description: { en: 'A sidebar.' } }, rows: [row(), row({ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', entryId: 'include:theme' as PluginEntryId, enabled: false, phase: null })], })], }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) const detail = document.querySelector('[data-plugin-detail="dsh-better-sidebar"]') as HTMLElement - expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('better-sidebar') + expect(within(detail).getByRole('heading', { level: 3 }).textContent).toBe('dsh-better-sidebar') // The version sits beside the name as a tag; the crumb only leads back. expect(within(detail).getByText('v0.16.0')).toBeTruthy() - // The full package name stays visible under the short name. expect(document.querySelector('[data-plugin-name]')?.textContent).toBe('dsh-better-sidebar') expect(within(detail).getByRole('button', { name: en.backToList }).textContent).toBe(en.crumbRoot) expect(within(detail).getByText('A sidebar.')).toBeTruthy() @@ -375,18 +591,18 @@ describe('PluginManagerPage', () => { expect(rows.map(item => item.getAttribute('data-plugin-row'))).toEqual(['include:sidebar', 'include:theme']) expect(rows[1]?.getAttribute('data-state')).toBe('off') expect(within(detail).getByText(en.partsCountTotal.replace('{count}', '2'), { exact: false })).toBeTruthy() - expect(within(detail).getByText('dsh-better-sidebar/theme')).toBeTruthy() + expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar/theme') })).toBeTruthy() expect(within(detail).getByText(en.rowPhaseActive)).toBeTruthy() expect(within(detail).getByText(en.partOff)).toBeTruthy() - fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })) + fireEvent.click(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })) expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar') - fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'better-sidebar') })) + fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })) expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false) // A problem and a protection the Host reports read on the page in the dictionary's words; the page leaves with the crumb. set({ packages: [pkg({ error: { code: 'operation-error', diagnostic: 'unreadable' }, readOnlyReason: 'management-required' })] }) expect(within(detail).getByText(`${en.reasonLabel}: unreadable`)).toBeTruthy() expect(within(detail).getByText(en.reasonManagementRequired)).toBeTruthy() - expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'better-sidebar') })).toHaveProperty('disabled', true) + expect(within(detail).getByRole('button', { name: en.uninstallLabel.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true) expect(within(detail).getByText(en.partsEmpty)).toBeTruthy() set({ packages: [pkg({ error: { code: 'not-bundle' } })] }) expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonNotBundle}`)).toBeTruthy() @@ -394,11 +610,11 @@ describe('PluginManagerPage', () => { expect(within(detail).getByText(`${en.reasonLabel}: ${en.reasonOperationError}`)).toBeTruthy() fireEvent.click(within(detail).getByRole('button', { name: en.backToList })) expect(document.querySelector('[data-plugin-detail]')).toBeNull() - // A bundle without a description or a version says so; one that leaves the list drops back to the cards. + // A bundle that leaves the list drops back to the cards. const { version: _version, ...unversioned } = pkg() set({ packages: [unversioned] }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) - expect(screen.getByText(en.noDescription)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) + expect(screen.queryByText('No description.')).toBeNull() expect(screen.queryByText(en.versionTag.replace('{version}', '0.16.0'))).toBeNull() set({ packages: [] }) expect(document.querySelector('[data-plugin-detail]')).toBeNull() @@ -420,19 +636,21 @@ describe('PluginManagerPage', () => { return { ...unmounted, enabled: false, phase: null } }) const { actions, set } = renderTab({ packages: [pkg({ rows })], busy: [rowKey('include:row-5')] }) - fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') })) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + const toggle = (id: string): HTMLElement => within(detail.querySelector(`[data-plugin-row="${id}"]`)!) + .getByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar') }) expect(within(detail).getByText(`${en.partsCountTotal.replace('{count}', '12')} · ${en.partsCountRunning.replace('{count}', '8')} · ${en.partsCountOff.replace('{count}', '1')} · ${en.partsCountFailed.replace('{count}', '1')}`)).toBeTruthy() - fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })) + fireEvent.click(toggle('include:row-0')) expect(actions.setRowEnabled).toHaveBeenCalledWith('include:row-0', false) // A protected row, a row without a live entry, and a row with a write in flight cannot be switched. - const locked = within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-1') }) + const locked = toggle('include:row-1') expect(locked).toHaveProperty('disabled', true) expect(locked.getAttribute('title')).toBe(en.reasonUnaddressable) - expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })).toHaveProperty('disabled', true) - fireEvent.click(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-2') })) + expect(toggle('row-2')).toHaveProperty('disabled', true) + fireEvent.click(toggle('row-2')) expect(actions.setRowEnabled).toHaveBeenCalledTimes(1) - expect(within(detail).getByRole('switch', { name: en.partToggle.replace('{name}', 'row-5') })).toHaveProperty('disabled', true) + expect(toggle('include:row-5')).toHaveProperty('disabled', true) expect(within(detail).getByText(en.rowPhaseFailed)).toBeTruthy() expect(within(detail).getByText(en.rowPhaseLoading)).toBeTruthy() expect(within(detail).getByText(en.rowPhaseUnloading)).toBeTruthy() @@ -448,7 +666,7 @@ describe('PluginManagerPage', () => { fireEvent.change(filter, { target: { value: '' } }) // A bundle that is off shows its rows without switches. set({ packages: [pkg({ enabled: false, rows: rows.slice(0, 2).map(item => ({ ...item, enabled: false, phase: null })) })] }) - expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'row-0') })).toBeNull() + expect(within(detail).queryByRole('switch', { name: en.partToggle.replace('{name}', 'dsh-better-sidebar') })).toBeNull() expect(within(detail).getAllByText(en.partOff)).toHaveLength(2) // A row without a fiber, on a bundle that is on, reads idle. set({ packages: [pkg({ rows: [row({ phase: null })] })] }) @@ -841,12 +1059,14 @@ describe('PluginManagerPage', () => { packages: [pkg(), pkg({ name: 'dsh-other' })], confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar' }, }) - expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'better-sidebar') })).toBeTruthy() + expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy() expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.cancel })) expect(actions.cancelConfirm).toHaveBeenCalledTimes(1) set({ confirm: { action: 'uninstall', packageName: 'dsh-other' } }) - expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'other') })).toBeTruthy() + expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-other') })).toBeTruthy() + set({ packages: [] }) + expect(screen.getByRole('dialog', { name: en.confirmUninstallTitle.replace('{name}', 'dsh-other') })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: en.confirmUninstall })) expect(actions.confirm).toHaveBeenCalledTimes(1) }) 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 e60bd80ee8..939d54fbf7 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 @@ -3,7 +3,7 @@ * outcomes become toasts, and how the install run folds its output. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' @@ -87,7 +87,8 @@ function bench(overrides: Partial>> = {} } const ctx = { remote: { pluginManager: plugins, pluginInventory: inventory } } as never const controller = new PluginManagerController(ctx) - const face = controller.inject(NO_CONFIG) + onTestFinished(() => { controller.dispose() }) + const face = controller.inject(NO_CONFIG, text => typeof text === 'string' ? text : text.en) const state = () => controller.getSnapshot() /** The request id of the run the dialog just handed to the Host. */ const started = async (): Promise => { @@ -172,6 +173,25 @@ describe('PluginManagerController', () => { install.resolve(ok({ ...failed(), application: 'cancelled' })) }) + it('keeps local package and row metadata in the loaded view without changing technical identities', async () => { + const meta = { title: { en: 'Sidebar', zh: '侧栏' }, description: 'Local package', error: 'locale/zh.json: invalid title' } + const rowMeta = { title: { en: 'Theme', zh: '主题' }, description: { en: 'Display options', zh: '显示选项' } } + const bundle: BundleInfo = { + ...BUNDLE, + meta, + rows: BUNDLE.rows.map(row => ({ ...row, meta: rowMeta })), + } + const { controller, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([bundle])) }) + + await controller.load() + + expect(state().packages[0]).toMatchObject({ name: BUNDLE.name, meta }) + expect(state().packages[0]!.meta).toBe(meta) + expect(state().packages[0]!.rows[0]).toMatchObject({ rowId: 'sidebar', moduleName: BUNDLE.name, entryId: ROW_ENTRY, meta: rowMeta }) + expect(state().packages[0]!.rows[0]!.meta).toBe(rowMeta) + expect(state().packages[0]!.error).toBeUndefined() + }) + it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => { const gate = deferred>>() const { plugins, inventory, face, state, controller } = bench({ diff --git a/packages/client/ui-plugin-manager/tsconfig.json b/packages/client/ui-plugin-manager/tsconfig.json index 3cd74d0ad4..931e716c86 100644 --- a/packages/client/ui-plugin-manager/tsconfig.json +++ b/packages/client/ui-plugin-manager/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 8915a02331..1f98f2a539 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-package-manifest": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 50cb060116..c5064e5441 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client' +import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' import { IconChevronDownOutlineRegular, IconSearchOutlineRegular, @@ -20,6 +21,8 @@ type AgentPresetRow = AgentPresetGroup['rows'][number] /** Registration-side Remote face used by the section. */ export interface PluginInventorySettingsTabInjected { + /** Resolve local package text in the current Client locale at render time. */ + resolveText: (text: LocalizedText) => string /** Page-local module synchronization, independent from the Host inventory. */ hooks: { clientSync: ObservableSnapshot } /** Retry the latest client graph without changing the Host composition. */ @@ -60,7 +63,7 @@ function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) } -/** Compact a module specifier without guessing whether its Loader id was generated. */ +/** Compact technical names for Settings without changing their module identity. */ function moduleShortName(moduleName: string): string { const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName return unscoped @@ -74,11 +77,21 @@ function entrySubtitle(entryId: string): string { return entryId.replace(/^include:/, '') } -/** Whether one row's module name or entry id matches the catalog query. */ -function matches(moduleName: string, entryId: string | null, normalizedQuery: string): boolean { +/** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */ +function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) { + const title = row.meta?.title + return { + title: typeof title === 'object' ? resolveText(title) : moduleShortName(title ?? row.moduleName), + description: row.meta?.description === undefined ? undefined : resolveText(row.meta.description) || undefined, + } +} + +/** Match translated text alongside the row's technical module and entry identities. */ +function matches(row: PluginInventoryEntry | AgentPresetRow, normalizedQuery: string, resolveText: PluginInventorySettingsTabInjected['resolveText']): boolean { if (normalizedQuery.length === 0) return true - return [moduleName, ...entryId === null ? [] : [entryId]] - .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) + const { title, description } = pluginText(row, resolveText) + return [row.moduleName, row.entryId, title, description] + .some(value => value?.toLocaleLowerCase().includes(normalizedQuery)) } /** The roster row shown when the preset switcher has no explicit choice. */ @@ -95,9 +108,14 @@ function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset } /** One expandable plugin card; the caller owns the trailing status content. */ -function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: { +function PluginCard({ + rowKey, moduleName, title, description, metadataError, entryId, trailing, ariaLabel, failed, expanded, onToggle, children, +}: { readonly rowKey: string readonly moduleName: string + readonly title: string + readonly description: string | undefined + readonly metadataError: string | undefined readonly entryId: string | null readonly trailing: ReactNode readonly ariaLabel: string @@ -108,6 +126,7 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, }): ReactNode { const open = expanded === rowKey const detailId = `plugin-details-${encodeURIComponent(rowKey)}` + const descriptionId = useId() return (
  • { onToggle(rowKey) }} > - {moduleShortName(moduleName)} + {title} {trailing} + {description === undefined ? null : {description}} {entryId === null ? null : {entrySubtitle(entryId)}} + {metadataError === undefined ? null :

    {metadataError}

    } {open ?
    {children}
    : null}
  • ) @@ -212,7 +234,7 @@ function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly lab /** Render the read-only plugin inventory: agent presets first, then the global plane. */ export function PluginInventorySettingsTab( - { list, presetName, t, useClientSync, retryClient }: PluginInventorySettingsTabProps, + { list, presetName, resolveText, t, useClientSync, retryClient }: PluginInventorySettingsTabProps, ): ReactNode { const clientSync = useClientSync(snapshot => snapshot) const sectionId = useId() @@ -262,8 +284,8 @@ export function PluginInventorySettingsTab( else regularEntries.push(entry) } - const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery) - const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery) + const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry, normalizedQuery, resolveText) + const rowMatch = (row: AgentPresetRow): boolean => matches(row, normalizedQuery, resolveText) const filteredFailed = failedEntries.filter(entryMatch) const filteredRegular = regularEntries.filter(entryMatch) const globalCount = filteredFailed.length + filteredRegular.length @@ -291,7 +313,7 @@ export function PluginInventorySettingsTab( /** Trailing status and detail facts for one row of the selected preset. */ const presetRowCard = (preset: AgentPresetGroup, row: AgentPresetRow, index: number): ReactNode => { const key = `preset:${preset.id}:${String(index)}` - const title = moduleShortName(row.moduleName) + const { title, description } = pluginText(row, resolveText) const failed = row.fiberPhase === 'failed' const stateText = failed ? t('failedTag') @@ -302,6 +324,9 @@ export function PluginInventorySettingsTab( key={key} rowKey={key} moduleName={row.moduleName} + title={title} + description={description} + metadataError={row.meta?.error === undefined ? undefined : t('metadataError', { error: row.meta.error })} entryId={row.entryId} failed={failed} expanded={expanded} @@ -337,7 +362,7 @@ export function PluginInventorySettingsTab( providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]], ): ReactNode => { const key = `global:${entry.entryId}` - const title = moduleShortName(entry.moduleName) + const { title, description } = pluginText(entry, resolveText) const failed = entry.fiberPhase === 'failed' const stateText = failed ? t('failedTag') @@ -348,6 +373,9 @@ export function PluginInventorySettingsTab( key={key} rowKey={key} moduleName={entry.moduleName} + title={title} + description={description} + metadataError={entry.meta?.error === undefined ? undefined : t('metadataError', { error: entry.meta.error })} entryId={entry.entryId} failed={failed} expanded={expanded} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/index.ts b/packages/client/ui-settings-plugin-inventory/src/client/index.ts index 026f7da686..7d7117554f 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/index.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/index.ts @@ -48,6 +48,7 @@ export function apply(ctx: ClientContext): void { presetDisplayText(preset, agentPresetCopy).name const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName, + resolveText: text => ctx.locale.resolveText(text), hooks: { clientSync: ctx.modules.entries.state }, retryClient: () => { void ctx.modules.entries.retry().catch((error: unknown) => { ctx.logger.error(error) }) }, }) diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 11e8986eef..e4063d238c 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -8,6 +8,7 @@ export const zh = { clientSyncFailed: '本页面的插件未能完成同步;服务端的启用状态保持不变。', clientSyncRetry: '重试本页面同步', error: '暂时无法读取插件。', + metadataError: '包元信息错误:{error}', retry: '重试', search: '搜索插件', empty: '暂无插件。', @@ -54,6 +55,7 @@ export const en = { clientSyncFailed: 'Some plugins could not sync on this page. Host enablement is unchanged.', clientSyncRetry: 'Retry this page', error: 'Plugins are temporarily unavailable.', + metadataError: 'Package metadata error: {error}', retry: 'Retry', search: 'Search plugins', empty: 'No plugins are available.', diff --git a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx index 0dcaa5758a..22b7e218ae 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client' import { Context, Service } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { cleanup } from '@testing-library/react' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -23,6 +23,7 @@ type ListResult = async function bench() { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) const retryClient = vi.fn(async () => {}) ctx.provide('modules', { entries: { state: createSnapshotStore({ syncing: false, failures: [] }), retry: retryClient } } as unknown as ClientModuleLoader) await ctx.plugin(SlotRegistry).await() @@ -69,6 +70,11 @@ describe('ui-settings-plugin-inventory browser plugin', () => { expect(b.list).not.toHaveBeenCalled() const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)() + const text = { en: 'Local tools', zh: '本地工具' } + expect(injected.resolveText(text)).toBe('本地工具') + b.locale.setLocale('en') + expect(injected.resolveText(text)).toBe('Local tools') + b.locale.setLocale('zh') injected.retryClient() const retryError = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {}) b.retryClient.mockRejectedValueOnce(new Error('retry unavailable')) diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index 7c727f260e..de7274e7a5 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -1,15 +1,18 @@ // @vitest-environment jsdom import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ClientEntryState } from '@deepseek-ai/dsh-client-modules/client' +import type { PluginEntryId } from '@deepseek-ai/dsh-api-remotes/client' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx' import type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps, } from '../src/client/PluginInventorySettingsTab.tsx' -import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts' +import { en, zh, type PluginInventoryLocaleKey } from '../src/client/locales.ts' afterEach(cleanup) @@ -23,16 +26,32 @@ const t = ((key: PluginInventoryLocaleKey, params?: Record): str function props( list: PluginInventorySettingsTabInjected['list'], presetName: PluginInventorySettingsTabInjected['presetName'] = preset => preset.name ?? preset.id, + resolveText: PluginInventorySettingsTabInjected['resolveText'] = text => typeof text === 'string' ? text : text.en, ): PluginInventorySettingsTabProps { return { t, list, presetName, + resolveText, useClientSync: bindSnapshotSelector(createSnapshotStore({ syncing: false, failures: [] })), retryClient: vi.fn(), } as PluginInventorySettingsTabProps } +function localizedProps(list: PluginInventorySettingsTabInjected['list']) { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const locale = new LocaleRuntime(ctx) + ctx.effect(() => locale.register('settings.pluginInventory', 'en', en), 'test: English inventory dictionary') + ctx.effect(() => locale.register('settings.pluginInventory', 'zh', zh), 'test: Chinese inventory dictionary') + locale.setLocale('en') + const pageProps: PluginInventorySettingsTabProps = { + ...props(list, preset => preset.name ?? preset.id, text => locale.resolveText(text)), + t: locale.bind('settings.pluginInventory'), + } + return { locale, pageProps } +} + /** A deployment with a roster: one failed global row, two preset-provided rows. */ const SNAPSHOT = { entries: [ @@ -90,6 +109,208 @@ const globalToggle = (): HTMLElement => const presetToggle = (): HTMLElement => screen.getByRole('button', { name: en.presetTitle }) describe('PluginInventorySettingsTab', () => { + it.each(['global', 'preset'])('shortens package and module name fallbacks in the %s inventory', async (scope) => { + const names = [ + ['@deepseek-ai/dsh-tool-subagent', 'tool-subagent'], + ['@deepseek-ai/dsh-host-web', 'web'], + ['@deepseek-ai/dsh-client-tabs', 'tabs'], + ['@deepseek-ai/cordis-plugin-hmr', 'hmr'], + ['cordis:timer', 'timer'], + ['@acme/dsh-sidebar/navigation', 'sidebar/navigation'], + ['plain-plugin', 'plain-plugin'], + ] as const + const rows = names.flatMap(([moduleName], index) => [false, true].map(fromManifest => ({ + entryId: `include:short-${String(index)}-${String(fromManifest)}` as PluginEntryId, + moduleName, + enabled: true, + fiberPhase: null, + ...fromManifest ? { meta: { title: moduleName, description: 'Package description.' } } : {}, + }))) + const snapshot: Snapshot = scope === 'global' + ? { entries: rows } + : { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] } + const view = await renderReady(snapshot) + fireEvent.click(scope === 'global' ? globalToggle() : presetToggle()) + for (const [index, [moduleName, title]] of names.entries()) { + for (const fromManifest of [false, true]) { + const entryId = `include:short-${String(index)}-${String(fromManifest)}` + const card = screen.getByRole('button', { name: `${title}, ${entryId}, Enabled` }) + if (fromManifest) { + expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe('Package description.') + } + fireEvent.click(card) + expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe(moduleName) + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe(entryId) + fireEvent.click(card) + } + } + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { + target: { value: '@deepseek-ai/dsh-tool-subagent' }, + }) + expect(screen.getAllByRole('listitem')).toHaveLength(2) + expect(screen.getAllByRole('button', { name: /^tool-subagent, include:short-0-/ })).toHaveLength(2) + }) + + it('keeps authored locale titles verbatim when they resemble module names', async () => { + const { locale, pageProps } = localizedProps(async () => ({ + entries: [{ + entryId: 'include:navigation' as PluginEntryId, moduleName: '@acme/dsh-navigation', enabled: true, fiberPhase: null, + meta: { title: { en: 'dsh-Navigation', zh: 'dsh-导航' } }, + }], + })) + const view = render() + await screen.findByRole('searchbox', { name: en.search }) + fireEvent.click(globalToggle()) + expect(screen.getByRole('button', { name: 'dsh-Navigation, include:navigation, Enabled' })).toBeTruthy() + locale.setLocale('zh') + view.rerender() + expect(screen.getByRole('button', { name: 'dsh-导航, include:navigation, 已启用' })).toBeTruthy() + }) + + it('localizes global and preset metadata at render time while preserving identities and query state', async () => { + const globalError = 'locale/zh.json: invalid title' + const presetError = 'locale/fr.json: invalid description' + const snapshot: Snapshot = { + entries: [{ + entryId: 'include:global-navigation' as PluginEntryId, moduleName: '@acme/navigation', enabled: true, fiberPhase: 'active', + meta: { + title: { en: 'Navigation', zh: '导航' }, + description: { en: 'Global navigation controls', zh: '全局导航控件' }, + error: globalError, + }, + }], + agentPresets: [{ + id: 'custom', trust: 'user', name: 'My preset', isDefault: true, + rows: [{ + entryId: 'include:preset-runner', moduleName: '@acme/runner', enabled: true, fiberPhase: null, + meta: { + title: { en: 'Session runner', zh: '会话执行器' }, + description: { en: 'Run per session', zh: '运行会话命令' }, + error: presetError, + }, + }], + }], + } + const list = vi.fn(async () => snapshot) + const { locale, pageProps } = localizedProps(list) + const view = render() + await screen.findByRole('searchbox', { name: en.search }) + fireEvent.click(globalToggle()) + fireEvent.click(presetToggle()) + const global = screen.getByRole('button', { name: 'Navigation, include:global-navigation, Enabled' }) + const preset = screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' }) + expect(document.getElementById(global.getAttribute('aria-describedby')!)?.textContent).toBe('Global navigation controls') + expect(document.getElementById(preset.getAttribute('aria-describedby')!)?.textContent).toBe('Run per session') + expect(screen.getByText(en.metadataError.replace('{error}', globalError))).toBeTruthy() + expect(screen.getByText(en.metadataError.replace('{error}', presetError))).toBeTruthy() + expect(global.closest('li')?.getAttribute('data-failed')).toBeNull() + fireEvent.click(global) + expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/navigation') + fireEvent.click(preset) + expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/runner') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner') + + locale.setLocale('zh') + view.rerender() + expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy() + expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy() + expect(screen.getByText('全局导航控件')).toBeTruthy() + expect(screen.getByText('运行会话命令')).toBeTruthy() + expect(screen.getByText(zh.metadataError.replace('{error}', presetError))).toBeTruthy() + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:preset-runner') + const search = screen.getByRole('searchbox', { name: zh.search }) + for (const query of ['导航', '全局导航控件', '@acme/navigation', 'include:global-navigation']) { + fireEvent.change(search, { target: { value: query } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByRole('button', { name: '导航, include:global-navigation, 已启用' })).toBeTruthy() + } + fireEvent.change(search, { target: { value: '运行会话命令' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByRole('button', { name: '会话执行器, include:preset-runner, 已启用' })).toBeTruthy() + fireEvent.change(search, { target: { value: 'Run per session' } }) + expect(screen.getByText(zh.emptySearch)).toBeTruthy() + locale.setLocale('en') + view.rerender() + expect(screen.getByRole('button', { name: 'Session runner, include:preset-runner, Enabled' })).toBeTruthy() + expect(screen.getByRole('searchbox', { name: en.search })).toHaveProperty('value', 'Run per session') + expect(list).toHaveBeenCalledOnce() + }) + + it.each(['global', 'preset'])('resolves each %s row field independently and hides empty English descriptions', async (scope) => { + const rows: Snapshot['entries'] = [ + { + entryId: 'include:navigation' as PluginEntryId, moduleName: '@acme/dsh-sidebar/navigation', enabled: true, fiberPhase: null, + meta: { description: { en: 'Navigation description.' } }, + }, + { + entryId: 'include:commands' as PluginEntryId, moduleName: '@acme/dsh-sidebar/commands', enabled: true, fiberPhase: null, + meta: { title: { en: 'English title' }, description: { en: 'Package description.', zh: '中文命令说明。' } }, + }, + { + entryId: 'include:theme' as PluginEntryId, moduleName: '@acme/dsh-theme/client', enabled: true, fiberPhase: null, + meta: { title: { en: '@acme/dsh-theme', zh: '主题插件' }, description: { en: '', zh: '中文主题说明。' } }, + }, + ] + const snapshot: Snapshot = scope === 'global' + ? { entries: rows } + : { entries: [], agentPresets: [{ id: 'custom', trust: 'user', isDefault: true, rows }] } + const list = vi.fn(async () => snapshot) + const { locale, pageProps } = localizedProps(list) + const view = render() + await screen.findByRole('searchbox', { name: en.search }) + fireEvent.click(scope === 'global' ? globalToggle() : presetToggle()) + expect(screen.getByRole('button', { name: 'sidebar/navigation, include:navigation, Enabled' })).toBeTruthy() + expect(screen.getByText('Navigation description.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'English title, include:commands, Enabled' })).toBeTruthy() + expect(screen.getByText('Package description.')).toBeTruthy() + const theme = screen.getByRole('button', { name: '@acme/dsh-theme, include:theme, Enabled' }) + expect(theme.getAttribute('aria-describedby')).toBeNull() + expect(screen.queryByText('中文主题说明。')).toBeNull() + + locale.setLocale('zh') + view.rerender() + for (const [name, description] of [ + ['sidebar/navigation, include:navigation, 已启用', 'Navigation description.'], + ['English title, include:commands, 已启用', '中文命令说明。'], + ['主题插件, include:theme, 已启用', '中文主题说明。'], + ] as const) { + const card = screen.getByRole('button', { name }) + expect(screen.getByText(description)).toBeTruthy() + expect(document.getElementById(card.getAttribute('aria-describedby')!)?.textContent).toBe(description) + } + expect(screen.queryByText('Package description.')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '主题插件, include:theme, 已启用' })) + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('include:theme') + expect(screen.getByText(zh.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-theme/client') + + fireEvent.change(screen.getByRole('searchbox', { name: zh.search }), { target: { value: '中文主题说明。' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + locale.setLocale('en') + view.rerender() + expect(screen.getByText(en.emptySearch)).toBeTruthy() + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '@acme/dsh-theme/client' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByRole('button', { name: '@acme/dsh-theme, include:theme, Enabled' }).getAttribute('aria-describedby')).toBeNull() + expect(list).toHaveBeenCalledOnce() + }) + + it('keeps metadata-error-only rows inspectable by their full module specifier', async () => { + const error = 'locale/zh.json: invalid title' + await renderReady({ + entries: [{ + entryId: 'include:legacy' as PluginEntryId, moduleName: '@acme/dsh-legacy', enabled: false, fiberPhase: null, + meta: { error }, + }], + }) + fireEvent.click(globalToggle()) + expect(screen.getByText(en.metadataError.replace('{error}', error))).toBeTruthy() + const card = screen.getByRole('button', { name: 'legacy, include:legacy, Disabled' }) + expect(card).toHaveProperty('disabled', false) + expect(card.closest('li')?.getAttribute('data-failed')).toBeNull() + fireEvent.click(card) + expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@acme/dsh-legacy') + }) + it('shows the default preset first with both groups collapsed', async () => { const view = await renderReady() diff --git a/packages/client/ui-settings-plugin-inventory/tsconfig.json b/packages/client/ui-settings-plugin-inventory/tsconfig.json index 1a8569048d..491b34792f 100644 --- a/packages/client/ui-settings-plugin-inventory/tsconfig.json +++ b/packages/client/ui-settings-plugin-inventory/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/package-manifest" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/experimental/agent-team-profile/locale/en.json b/packages/experimental/agent-team-profile/locale/en.json new file mode 100644 index 0000000000..b69c01b6c7 --- /dev/null +++ b/packages/experimental/agent-team-profile/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Agent Teams", + "description": "Enable team collaboration, team tools, the member roster, and the shared task board." + } +} diff --git a/packages/experimental/agent-team-profile/locale/zh.json b/packages/experimental/agent-team-profile/locale/zh.json new file mode 100644 index 0000000000..4943f297e7 --- /dev/null +++ b/packages/experimental/agent-team-profile/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "智能体团队", + "description": "启用团队协作、团队工具、成员列表和共享任务看板。" + } +} diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index a8b8967147..73999ee5d4 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -19,10 +19,12 @@ "default": "./lib/index.js" }, "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "cordis.patch.yml", "lib/types/**/*.d.ts" diff --git a/packages/experimental/agent-team/locale/en.json b/packages/experimental/agent-team/locale/en.json new file mode 100644 index 0000000000..5d93c64811 --- /dev/null +++ b/packages/experimental/agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Team Collaboration", + "description": "Manage team members, member messages, and shared tasks." + } +} diff --git a/packages/experimental/agent-team/locale/zh.json b/packages/experimental/agent-team/locale/zh.json new file mode 100644 index 0000000000..5c27bbbf75 --- /dev/null +++ b/packages/experimental/agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "团队协作", + "description": "管理团队成员、成员消息和共享任务。" + } +} diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 8a1ebd621d..db127f9f7e 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -38,10 +38,12 @@ "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" }, + "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", diff --git a/packages/experimental/auto-review/locale/en.json b/packages/experimental/auto-review/locale/en.json new file mode 100644 index 0000000000..54c040dfcd --- /dev/null +++ b/packages/experimental/auto-review/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Auto Authorization Review", + "description": "Add an Auto review permission mode that uses the model to assess authorization before each tool call." + } +} diff --git a/packages/experimental/auto-review/locale/zh.json b/packages/experimental/auto-review/locale/zh.json new file mode 100644 index 0000000000..6a9005d0a1 --- /dev/null +++ b/packages/experimental/auto-review/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "自动授权审查", + "description": "提供自动审查权限模式,由模型在每次工具调用前判断是否授权。" + } +} diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 853cc277c6..f75c46c8da 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -15,11 +15,13 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json", "./cordis.patch.yml": "./cordis.patch.yml" }, "files": [ + "locale/*.json", "lib/index.js", "cordis.patch.yml", "lib/types/**/*.d.ts" diff --git a/packages/experimental/client-ui-agent-team/locale/en.json b/packages/experimental/client-ui-agent-team/locale/en.json new file mode 100644 index 0000000000..ad6f1665e5 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Team Panel", + "description": "View team members and shared tasks, and navigate to member conversations." + } +} diff --git a/packages/experimental/client-ui-agent-team/locale/zh.json b/packages/experimental/client-ui-agent-team/locale/zh.json new file mode 100644 index 0000000000..383f0dfec9 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "团队面板", + "description": "查看团队成员和共享任务,并切换到成员会话。" + } +} diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index b344f4cfe0..c980e32c16 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -22,6 +22,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -79,6 +80,7 @@ "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/experimental/tool-agent-team/locale/en.json b/packages/experimental/tool-agent-team/locale/en.json new file mode 100644 index 0000000000..f517900c98 --- /dev/null +++ b/packages/experimental/tool-agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Team Tools", + "description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks." + } +} diff --git a/packages/experimental/tool-agent-team/locale/zh.json b/packages/experimental/tool-agent-team/locale/zh.json new file mode 100644 index 0000000000..fee0b1da5e --- /dev/null +++ b/packages/experimental/tool-agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "团队工具", + "description": "为智能体提供成员协调、消息通信和共享任务管理工具。" + } +} diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index f638578aac..f1436528f3 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -18,10 +18,12 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.d.ts" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01edd8cb6a..ba8a30e914 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2471,6 +2471,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-package-manifest': + specifier: workspace:^ + version: link:../../util/package-manifest '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3767,6 +3770,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-package-manifest': + specifier: workspace:^ + version: link:../../util/package-manifest '@deepseek-ai/dsh-plugin-manager': specifier: workspace:^ version: link:../../boot/plugin-manager @@ -4210,6 +4216,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-package-manifest': + specifier: workspace:^ + version: link:../../util/package-manifest '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From 8ceaa725e0b0289a644c35df369fcae254f49792 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:56:02 +0800 Subject: [PATCH 06/10] build(plugins): validate locale resources and refresh catalogs --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 8 +- docs/subsystems/boot.zh.md | 8 +- package.json | 1 + .../src/client/slot-catalog.ts | 10 +- .../extensions/tool-cordis/src/api-catalog.ts | 16 +- scripts/check-workspace-constraints.spec.ts | 45 ++ scripts/check-workspace-constraints.ts | 12 + scripts/run-gates.ts | 1 + scripts/verify-package-meta.spec.ts | 416 ++++++++++++++++++ scripts/verify-package-meta.ts | 192 ++++++++ 17 files changed, 706 insertions(+), 31 deletions(-) create mode 100644 scripts/verify-package-meta.spec.ts create mode 100644 scripts/verify-package-meta.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a3e2cdf686..42f630c481 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 9f9a47ff24f67dfda288c046b482ec8a1127787c -config-catalog.zh.md: 95d9bb8415f5f7bb116f58bbd8f9a46c3cf42182 +config-catalog.md: 316670a432a2f608140d275aa1a69c11febef254 +config-catalog.zh.md: bc16d612f0f699fab61728c068c1d237b4b54c14 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9f9a47ff24..316670a432 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1906,7 +1906,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:35`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 95d9bb8415..bc16d612f0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1908,7 +1908,7 @@ export interface Config { } ``` -来源: [`packages/boot/plugin-manager/src/index.ts:35`](../packages/boot/plugin-manager/src/index.ts) +来源: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 93b1ac5fdc..f3dd91b507 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 550af905e80b1165df5e9d287bbf054f2989e5f0 -event-producer-consumer.zh.md: 1688477fca26ccbc561ff48ba01bb33cd2e58b6f +event-producer-consumer.md: c26ca2d18995eb4e883815fd4f22ec4773d10728 +event-producer-consumer.zh.md: 588ec67cd55f40b5544b81603d23e65f3ebe1119 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 550af905e8..c26ca2d189 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -51,9 +51,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:223`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:229`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 1688477fca..588ec67cd5 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -53,9 +53,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:223`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:229`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index fdf1602146..c602a72982 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 90ad5c760823e2451e7d138288778c0b2bfa625f -boot.zh.md: 7a08fb8a8640ba0a6ad8c174ac7eb2fe433f6ab6 +boot.md: d8f7ea8018dbbf5ec3b20f14cf4c1884af167a94 +boot.zh.md: 3353427b4e727713113842b89e094f26e75217bb diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 90ad5c7608..d8f7ea8018 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -8,9 +8,9 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id. -`PluginInfo` carries module identity, effective enablement and fiber phase, plus a unique `patchId` or a `readOnlyReason`. +`PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. +`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. `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. @@ -69,8 +69,8 @@ Manage profile files and apply their declared reload lifecycle. /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. - * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the - * bundle, and removal availability. + * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections, + * whether the installation offers the bundle, and removal availability. */ @Remote listBundles(): Promise diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 7a08fb8a86..3353427b4e 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -8,9 +8,9 @@ `PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。 -`PluginInfo` 包含模块标识、实际启停状态和 fiber 阶段,以及唯一的 `patchId` 或 `readOnlyReason`。 +`PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。 +`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 `InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 @@ -69,8 +69,8 @@ Manage profile files and apply their declared reload lifecycle. /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. - * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the - * bundle, and removal availability. + * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections, + * whether the installation offers the bundle, and removal availability. */ @Remote listBundles(): Promise diff --git a/package.json b/package.json index 4486d5435c..38aa2b527f 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "verify-dsh-package-licenses": "tsx scripts/verify-dsh-package-licenses.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", + "verify-package-meta": "tsx scripts/verify-package-meta.ts", "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", "verify-package-readme-summaries": "tsx scripts/verify-package-readme-summaries.ts", diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 44dd4aa8fb..cf1de6b8bc 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1671,7 +1671,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.bundle.config\', () => ctx.slots.register(\n { name: \'plugins.bundle.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:38', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:39', }, { key: 'plugins.item', @@ -1724,14 +1724,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.item\', () => ctx.slots.register(\n { name: \'plugins.item\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:32', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:33', }, { key: 'plugins.row.config', kind: 'keyed', scope: 'root', - summary: 'The configuration of one row a bundle declares, keyed by `#` with the row id as the bundle\'s patch declares it: the row on the bundle\'s page gains a configure control that opens the entry\'s page, headed by the row id and the entry\'s summary.', - doc: 'The configuration of one row a bundle declares, keyed by\n`#` with the row id as the bundle\'s patch declares\nit: the row on the bundle\'s page gains a configure control that opens\nthe entry\'s page, headed by the row id and the entry\'s summary.', + summary: 'The configuration of one row a bundle declares, keyed by `#` with the row id as the bundle\'s patch declares it: the row on the bundle\'s page gains a configure control that opens the entry\'s page, headed by the plugin\'s display title and description.', + doc: 'The configuration of one row a bundle declares, keyed by\n`#` with the row id as the bundle\'s patch declares\nit: the row on the bundle\'s page gains a configure control that opens\nthe entry\'s page, headed by the plugin\'s display title and description.\nAn absent description falls back to the entry\'s `view: \'summary\'`.', registerOptions: [ { name: 'key', @@ -1760,7 +1760,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'plugins.row.config\', () => ctx.slots.register(\n { name: \'plugins.row.config\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:45', + source: 'packages/client/ui-plugin-manager/src/client/slot-contract.ts:47', }, { key: 'rightbar', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index de5ef44f14..7035c5dc07 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1527,7 +1527,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote listBundles(): Promise', description: 'Read the profile\'s installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.', parameters: [], - returns: 'Package versions, one-liners, rows, activation selections, whether the installation offers the bundle, and removal availability.', + returns: 'Package versions, manifest descriptions, rows, optional display metadata, activation selections, whether the installation offers the bundle, and removal availability.', }, { signature: '@Remote async registries(): Promise', @@ -4281,11 +4281,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleInfo', - declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', + declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n meta?: PackageMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', }, { name: 'BundleRowInfo', - declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n entryId?: PluginEntryId;\n}', + declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n meta?: PackageMeta;\n entryId?: PluginEntryId;\n}', }, { name: 'ChangeResult', @@ -5083,6 +5083,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmRuntime', declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n fileRequestText(ref: FileAttachmentRef): string;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions) /* …truncated — full shape in source */', }, + { + name: 'LocalizedText', + declaration: 'export type LocalizedText = string | {\n readonly en: string;\n readonly [locale: string]: string;\n};', + }, { name: 'LspHover', declaration: 'export interface LspHover {\n readonly contents: string;\n readonly range?: LspRange;\n}', @@ -5307,6 +5311,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OptionalSessionSeq', declaration: 'export type OptionalSessionSeq = SessionSeq | null;', }, + { + name: 'PackageMeta', + declaration: 'export interface PackageMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly error?: string;\n}', + }, { name: 'PackageResult', declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n}', @@ -5357,7 +5365,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginInventoryEntry', - declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', + declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: PackageMeta;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', }, { name: 'PluginRegistries', diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index c824e41b3d..0a06641c14 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -164,6 +164,51 @@ describe('dsh family version coherence', () => { }) describe('package payload constraints', () => { + it.each([ + { exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] }, + { exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] }, + { exports: { './locale/en.json': './locale/en.json', './locale/zh.json': './locale/zh.json' }, resources: ['locale/en.json', 'locale/zh.json'] }, + { exports: { './locale/*.json': { default: './locale/*.json' } }, resources: ['locale/*.json'] }, + { exports: { './locale/*.json': './locale/*.json', './search/locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] }, + { exports: { './search/locale/*.json': './z/*.json', './locale/*.json': './a/*.json' }, resources: ['a/*.json', 'z/*.json'] }, + ])('includes declared locale resources in the canonical payload: $exports', ({ exports, resources }) => { + expect(expectedDshPackageFiles({ name: '@deepseek-ai/dsh-localized', exports })).toEqual([ + ...resources, 'lib/index.js', 'lib/types/**/*.d.ts', + ]) + }) + + it('does not infer locale payloads from unrelated or non-JSON exports', () => { + expect(expectedDshPackageFiles({ + exports: { + './config.json': './config.json', + './locale/README.md': './locale/README.md', + './locale/en.json': './metadata.js', + './locale/zh.json': null, + './locale/fr.json': { types: './locale/fr.d.ts' }, + }, + })).toEqual(['lib/index.js', 'lib/types/**/*.d.ts']) + }) + + it.each(['agent-team', 'agent-team-profile', 'auto-review', 'client-ui-agent-team', 'tool-agent-team'])( + 'accepts the published locale files for %s and rejects their omission', (name) => { + const dir = `packages/experimental/${name}` + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + expect(checkWorkspaceManifest({ dir, manifest: { + ...manifest, files: manifest.files!.filter(file => file !== 'locale/*.json'), + } })).toEqual([expect.stringContaining('package.json files must be')]) + }, + ) + + it('rejects locale publication entries without their resource exports', () => { + const dir = 'packages/experimental/auto-review' + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + const exports = { ...manifest.exports } + delete exports['./locale/*.json'] + expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, exports } })) + .toEqual([expect.stringContaining('package.json files must be')]) + }) + it('includes a declared profile patch without a package-name allowlist', () => { expect(expectedDshPackageFiles({ name: '@deepseek-ai/dsh-private-profile', diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 7e9dea5287..2372546421 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -209,7 +209,18 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index]) } +/** + * Compute canonical publication patterns, including explicitly exported locale JSON resources. + * @param manifest - workspace package manifest. + * @returns deduplicated locale targets followed by the package's runtime and declaration payloads. + */ export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { + const localeFiles = new Set() + for (const resource of Object.keys(manifest.exports ?? {})) { + if (!/^\.\/(?:.+\/)?locale\/[^/]+\.json$/u.test(resource)) continue + const target = exportDefault(manifest, resource) + if (target?.startsWith('./') && target.endsWith('.json')) localeFiles.add(target.slice(2)) + } const declaredPatch = manifest.dsh?.bundle?.patch const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')] const extras = [ @@ -217,6 +228,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []), ] return [ + ...[...localeFiles].sort(), 'lib/index.js', // Packages with an invariant export publish its runtime as a separate // bundle; the package-invariant gate validates the source/export pairing. diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index aa308e66fa..4aae3bcf38 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -347,6 +347,7 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }), pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), + pnpmScript('package-meta', 'verify-package-meta', { label: 'package metadata' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), ...sharedHygieneGates(), pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }), diff --git a/scripts/verify-package-meta.spec.ts b/scripts/verify-package-meta.spec.ts new file mode 100644 index 0000000000..fbdd05daf8 --- /dev/null +++ b/scripts/verify-package-meta.spec.ts @@ -0,0 +1,416 @@ +/** Plugin metadata checks use private source-only packages and real resource exports. */ + +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { packageMetaProblems } from './verify-package-meta.ts' + +let root: string +let dir: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-meta-gate-')) + dir = join(root, 'packages', 'test', 'plugin') + mkdirSync(dir, { recursive: true }) +}) +afterEach(() => { rmSync(root, { recursive: true, force: true }) }) + +function file(path: string, contents: string): void { + mkdirSync(dirname(join(dir, path)), { recursive: true }) + writeFileSync(join(dir, path), contents) +} + +function json(path: string, contents: unknown): void { + file(path, JSON.stringify(contents)) +} + +function manifest(fields: object = {}): void { + json('package.json', { + name: '@test/plugin', + description: 'Package description', + type: 'module', + exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': './locale/*.json' }, + files: ['lib', 'locale'], + ...fields, + }) +} + +it('rejects an empty workspace package corpus', () => { + expect(packageMetaProblems(root).join('\n')).toContain('no workspace package manifests') +}) + +it('does not require locale resources for exported package name and description', () => { + manifest({ exports: { '.': './lib/index.js', './package.json': './package.json' }, files: ['lib'] }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('excludes tests, installed dependencies, and build output from metadata discovery', () => { + manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] }) + json('tests/fixtures/locale/en.json', { meta: { title: false } }) + json('node_modules/dependency/locale/en.json', { meta: { title: false } }) + file('lib/locale/en.json', '{') + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('ignores unrelated locale content without requiring metadata exports', () => { + manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] }) + json('locale/zh.json', { buttons: { save: '保存' }, enabled: true, other: null }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('ignores ordinary JSON metadata outside locale directories and resource exports', () => { + manifest({ exports: { '.': './lib/index.js', './data.json': './data.json' } }) + json('data.json', { meta: { title: false, schemaVersion: 1 } }) + json('resources/search/en.json', { meta: { description: 'Business data' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('accepts direct root metadata and per-field English fallback without built entries', () => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin', description: '%literal%' }, buttons: { save: 'Save' } }) + json('locale/zh.json', { meta: { title: '插件' }, nested: [1, null] }) + json('locale/pt-BR.json', { meta: { description: 'Descrição' } }) + file('locale/README.txt', 'Not a JSON resource.') + expect(packageMetaProblems(root)).toEqual([]) + expect(existsSync(join(dir, 'lib'))).toBe(false) +}) + +it.each(['en', 'zh'])('rejects %s metadata resolved into build output without reading that JSON', (language) => { + manifest({ + exports: { + './locale/*.json': './locale/*.json', + [`./locale/${language}.json`]: `./lib/locale/${language}.json`, + }, + }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + file(`lib/locale/${language}.json`, '{') + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain(`${language}.json`) + expect(problems).toContain('source JSON') + expect(problems).not.toContain('Plugin metadata for') +}) + +it('reads static metadata without evaluating the plugin entry', () => { + manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } }) + file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('plugin entry executed')") + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root)).toEqual([]) + expect(existsSync(join(dir, 'executed'))).toBe(false) +}) + +it('accepts metadata from an unscoped package self-reference', () => { + manifest({ name: 'plugin' }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('validates root and multiple exported plugins independently', () => { + manifest({ + exports: { + '.': './lib/index.js', + './search': './lib/search.js', + './write': './lib/write.js', + './locale/*.json': './locale/root/*.json', + './search/locale/*.json': './locale/search/*.json', + './write/locale/*.json': './locale/write/*.json', + }, + }) + json('locale/root/en.json', { meta: { title: 'Bundle' } }) + json('locale/search/en.json', { meta: { title: 'Search' } }) + json('locale/search/zh.json', { meta: { title: '搜索' } }) + json('locale/write/en.json', { meta: { description: 'Write files' } }) + json('locale/write/zh.json', { meta: { description: '写入文件' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('accepts missing English fields in independently exported plugin metadata', () => { + manifest({ + exports: { + '.': './lib/index.js', + './search': './lib/search.js', + './locale/*.json': './locale/root/*.json', + './search/locale/*.json': './locale/search/*.json', + }, + }) + json('locale/root/en.json', { meta: { description: 'Bundle description' } }) + json('locale/search/en.json', { meta: { title: 'Search' } }) + json('locale/search/zh.json', { meta: { description: '搜索文件' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([ + { '.': './lib/index.js' }, + { '.': './lib/index.js', './locale/*.json': null }, + { '.': './lib/index.js', './locale/*.json': { require: './locale/*.json' } }, + { '.': './lib/index.js', './locale/*.json': './locale/*.json', './locale/en.json': null }, +])('rejects a locale file that package exports do not expose: %j', (exports) => { + manifest({ exports: { ...exports, './package.json': './package.json' } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('locale/en.json') + expect(problems).toContain('exports') +}) + +it('requires exports for every translated resource', () => { + manifest({ exports: { '.': './lib/index.js', './locale/en.json': './locale/en.json' } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('locale/zh.json') + expect(problems).toContain('exports') +}) + +it.each(['locale/search', 'search/locale'])('discovers child metadata without its resource export: %s', (directory) => { + manifest({ exports: { './search': './lib/search.js' }, files: [directory] }) + json(`${directory}/en.json`, { meta: { title: 'Search' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain(`${directory}/en.json`) + expect(problems).toContain('exports') +}) + +it('rejects an inaccessible resource declared outside a locale directory', () => { + manifest({ + exports: { './search/locale/*.json': { require: './resources/search/*.json' } }, + files: ['resources/search'], + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('resources/search/en.json') + expect(problems).toContain('exports') +}) + +it.each([ + './resources/search/*.json', + { types: './missing/*.json', import: './resources/search/*.json', require: './missing/*.json' }, + { node: { import: './resources/search/*.json' }, default: './missing/*.json' }, + ['./resources/search/*.json'], +])('accepts resource exports to an independent physical directory: %j', (target) => { + manifest({ + exports: { './search': './lib/search.js', './search/locale/*.json': target }, + files: ['resources/search'], + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + json('resources/search/zh.json', { meta: { title: '搜索' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('accepts individually exported resources in an independent physical directory', () => { + manifest({ + exports: { + './search': './lib/search.js', + './search/locale/en.json': './resources/search/en.json', + './search/locale/zh.json': './resources/search/zh.json', + }, + files: ['resources'], + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + json('resources/search/zh.json', { meta: { title: '搜索' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('ignores metadata in an unselected exports condition', () => { + manifest({ + exports: { './locale/*.json': { import: './esm/*.json', require: './cjs/*.json' } }, + files: ['esm'], + }) + json('esm/en.json', { meta: { title: 'ESM plugin' } }) + json('cjs/en.json', { meta: { title: false } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([ + { default: './locale/*.json', import: './missing/*.json' }, + [null, './locale/*.json'], + ['../invalid/*.json', './locale/*.json'], +])('uses Node condition order and export-array fallback: %j', (target) => { + manifest({ exports: { './locale/*.json': target } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('does not fall through a valid exports target just because its file is missing', () => { + manifest({ exports: { './locale/*.json': ['./missing/*.json', './locale/*.json'] } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root).join('\n')).toContain('locale/en.json') +}) + +it('discovers root and child locale resources through a general export wildcard', () => { + manifest({ exports: { './*': './resources/*' }, files: ['resources'] }) + json('resources/locale/en.json', { meta: { title: 'Bundle' } }) + json('resources/search/locale/en.json', { meta: { title: 'Search' } }) + json('resources/review/locale/en.json', { meta: { title: 'Review' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('rejects an exact resource exclusion under a general export wildcard', () => { + manifest({ exports: { './*': './resources/*', './search/locale/en.json': null }, files: ['resources'] }) + json('resources/locale/en.json', { meta: { title: 'Bundle' } }) + json('resources/search/locale/en.json', { meta: { title: 'Search' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('search/locale/en.json') + expect(problems).toContain('exports') +}) + +it('discovers plugin wildcards with repeated target substitutions', () => { + manifest({ exports: { './*/locale/en.json': './resources/*/copy-*/en.json' }, files: ['resources'] }) + json('resources/search/copy-search/en.json', { meta: { title: 'Search' } }) + json('resources/review/copy-review/en.json', { meta: { title: 'Review' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('rejects files that do not share the repeated export-target substitution', () => { + manifest({ exports: { './*/locale/en.json': './locale/*/copy-*/en.json' }, files: ['locale'] }) + json('locale/search/copy-other/en.json', { meta: { title: 'Search' } }) + expect(packageMetaProblems(root).join('\n')).toContain('locale/search/copy-other/en.json') +}) + +it('rejects a missing exported locale target even when a conventional locale file exists', () => { + manifest({ exports: { '.': './lib/index.js', './locale/*.json': './missing/*.json' } }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + expect(packageMetaProblems(root).join('\n')).toContain('en.json') +}) + +it('rejects language resources mapped outside the English resource directory', () => { + manifest({ + exports: { + '.': './lib/index.js', + './locale/en.json': './locale/en.json', + './locale/zh.json': './elsewhere/zh.json', + }, + files: ['locale', 'elsewhere'], + }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + json('elsewhere/zh.json', { meta: { title: '其他' } }) + expect(packageMetaProblems(root).join('\n')).toContain('zh.json') +}) + +it.each([undefined, ['.'], ['./'], ['locale'], ['locale/'], ['./locale'], ['locale/*.json'], ['locale/**'], + ['locale/en.json', 'locale/zh.json']])( + 'accepts published locale resources: %j', + (files) => { + manifest({ files }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + expect(packageMetaProblems(root)).toEqual([]) + }, +) + +it.each([ + [[], 'en'], + [['lib'], 'en'], + [['locale/en.json'], 'zh'], + [['locale', '!locale/en.json'], 'en'], + [['locale', '!locale/*.json'], 'en'], + [['locale', '!locale'], 'en'], +] as const)('rejects omitted or excluded publication files: %j', (files, language) => { + manifest({ files }) + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { meta: { title: '插件' } }) + expect(packageMetaProblems(root).join('\n')).toContain(`files must include locale/${language}.json`) +}) + +it.each([{ files: ['locale'] }, { files: ['resources', '!resources/search/zh.json'] }])( + 'checks publication paths after resolving resource exports: %j', + ({ files }) => { + manifest({ + exports: { './search': './lib/search.js', './search/locale/*.json': './resources/search/*.json' }, + files, + }) + json('resources/search/en.json', { meta: { title: 'Search' } }) + json('resources/search/zh.json', { meta: { title: '搜索' } }) + expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search/') + }, +) + +it.each(['locale', 'resources/search'])('rejects translated metadata without an English resource: %s', (directory) => { + manifest({ + exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': `./${directory}/*.json` }, + files: [directory], + }) + json(`${directory}/zh.json`, { meta: { title: '插件' } }) + expect(packageMetaProblems(root).join('\n')).toContain('en.json') +}) + +it.each([ + { field: 'title', packageInfo: true }, + { field: 'description', packageInfo: true }, + { field: 'title', packageInfo: false }, + { field: 'description', packageInfo: false }, +])('accepts a missing English $field with package metadata $packageInfo', ({ field, packageInfo }) => { + manifest({ + exports: { + '.': './lib/index.js', + './locale/*.json': './locale/*.json', + ...packageInfo ? { './package.json': './package.json' } : {}, + }, + }) + json('locale/en.json', { meta: {} }) + json('locale/zh.json', { meta: { [field]: '译文' } }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it.each([null, [], false, 1, 'text'])('rejects malformed meta: %j', (meta) => { + manifest() + json('locale/en.json', { meta }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('en.json') + expect(problems).toContain('meta') +}) + +it.each([null, [], false, 1, 'text'])('rejects a non-object English locale document: %j', (contents) => { + manifest() + json('locale/en.json', contents) + expect(packageMetaProblems(root).join('\n')).toContain('en.json') +}) + +it.each([null, false, 1, {}, [], '', ' '])('rejects a malformed display field: %j', (value) => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin', description: 'About it' } }) + json('locale/zh.json', { meta: { title: value } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('zh.json') + expect(problems).toContain('title') +}) + +it.each(['en', 'zh'])('rejects malformed JSON in %s.json', (language) => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + file(`locale/${language}.json`, '{') + expect(packageMetaProblems(root).join('\n')).toContain(`${language}.json`) +}) + +it('rejects invalid language filenames', () => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/not_a_language.json', { meta: { title: 'Other' } }) + expect(packageMetaProblems(root).join('\n')).toContain('not_a_language.json') +}) + +it('rejects case-equivalent language files when the filesystem can hold both', (context) => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/EN.json', { meta: { title: 'Other' } }) + if (readdirSync(join(dir, 'locale')).length !== 2) context.skip() + expect(packageMetaProblems(root).join('\n')).toContain('duplicates locale en') +}) + +it('accepts locale files without metadata alongside an English introduction', () => { + manifest() + json('locale/en.json', { meta: { title: 'Plugin' } }) + json('locale/zh.json', { buttons: { save: '保存' }, other: null }) + expect(packageMetaProblems(root)).toEqual([]) +}) + +it('reports metadata failures from more than one workspace package', () => { + manifest() + json('locale/en.json', { meta: { title: false } }) + dir = join(root, 'packages', 'another', 'second') + manifest({ name: '@test/second' }) + json('locale/en.json', { meta: { title: 'Second' } }) + json('locale/zh.json', { meta: { description: '' } }) + const problems = packageMetaProblems(root).join('\n') + expect(problems).toContain('@test/plugin') + expect(problems).toContain('@test/second') +}) diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts new file mode 100644 index 0000000000..70dcc6d7ee --- /dev/null +++ b/scripts/verify-package-meta.ts @@ -0,0 +1,192 @@ +/** Validate source locale metadata, Node resource exports, and package publication selections. */ + +import { globSync, readFileSync, realpathSync } from 'node:fs' +import { basename, dirname, join, matchesGlob, relative, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot/src/package-meta.ts' + +interface Manifest { + name: string + exports?: unknown + files?: string[] +} + +interface SourceJson { + file: string + metadata: boolean + invalid: boolean +} + +function sourceJsonFiles(dir: string): SourceJson[] { + return globSync('**/*.json', { cwd: dir, exclude: ['node_modules', 'lib', 'tests'] }).map((path) => { + const file = path.replaceAll('\\', '/') + let contents: unknown + try { + contents = JSON.parse(readFileSync(join(dir, file), 'utf8')) as unknown + } catch (_error) { + return { file, metadata: false, invalid: true } + } + if (typeof contents !== 'object' || contents === null || Array.isArray(contents)) { + return { file, metadata: false, invalid: true } + } + return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false } + }) +} + +function targetsOf(value: unknown): string[] { + if (typeof value === 'string') return [value] + if (typeof value !== 'object' || value === null) return [] + return Object.values(value).flatMap(targetsOf) +} + +// Target matching discovers resource requests; Node selects conditions, arrays, and export precedence. +function substitution(pattern: string, file: string): string | undefined { + const index = pattern.indexOf('*') + if (index < 0) return pattern === file ? '' : undefined + const count = pattern.split('*').length - 1 + const width = (file.length - pattern.length + count) / count + if (width < 0 || !Number.isInteger(width)) return undefined + const value = file.slice(index, index + width) + return pattern.replaceAll('*', value) === file ? value : undefined +} + +function pluginOf(resource: string): string | undefined { + return /^(\..*)\/locale\/[^/]+\.json$/u.exec(resource)?.[1] +} + +function published(file: string, files: string[]): boolean { + const covered = (pattern: string): boolean => { + const normalized = pattern.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+$/u, '') + return normalized === '.' || normalized === '' + || matchesGlob(file, normalized) || matchesGlob(file, `${normalized}/**`) + } + return files.some(pattern => !pattern.startsWith('!') && covered(pattern)) + && !files.some(pattern => pattern.startsWith('!') && covered(pattern.slice(1))) +} + +function packageProblems(manifestPath: string): string[] { + const problems: string[] = [] + const dir = realpathSync(dirname(manifestPath)) + const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest + const documents = sourceJsonFiles(dir) + const byPath = new Map(documents.map(document => [join(dir, document.file), document])) + const parentURL = pathToFileURL(join(dir, 'package.json')).href + const specifierOf = (plugin: string): string => pkg.name + (plugin === '.' ? '' : plugin.slice(1)) + const candidates = new Set(['.']) + const alternatives = new Map>() + const exported = typeof pkg.exports === 'object' && pkg.exports !== null ? Object.entries(pkg.exports) : [] + for (const [key, target] of exported) { + if (!key.startsWith('.')) continue + if (!key.includes('*')) candidates.add(key) + const plugin = pluginOf(key) + if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin) + for (const pattern of targetsOf(target)) { + for (const document of documents) { + const value = substitution(pattern, `./${document.file}`) + if (value === undefined) continue + const request = key.replaceAll('*', value) + const owner = pluginOf(request) + if (owner === undefined) continue + candidates.add(owner) + let requests = alternatives.get(document.file) + if (requests === undefined) alternatives.set(document.file, requests = new Set()) + requests.add(pkg.name + request.slice(1)) + } + } + } + + const resolved = new Map() + const lookup = (resource: string): string | undefined => { + if (!resolved.has(resource)) { + try { + resolved.set(resource, resolvePluginResource(resource, parentURL)) + } catch (_error) { + // Missing exports and files are diagnosed against their source metadata below. + resolved.set(resource, undefined) + } + } + return resolved.get(resource) + } + const filenames = new Set(['en.json', ...documents.map(document => basename(document.file))]) + const claimed = new Set() + for (const candidate of candidates) { + const specifier = specifierOf(candidate) + const resourceOf = (filename: string): string => `${specifier}/locale/${filename}` + const resources = [...filenames].flatMap((filename) => { + const file = lookup(resourceOf(filename)) + return file === undefined ? [] : [{ filename, file }] + }) + if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue + for (const { file } of resources) claimed.add(file) + const english = lookup(resourceOf('en.json')) + if (english === undefined) { + problems.push(`${manifestPath}: exports must provide ${resourceOf('en.json')} as the locale discovery baseline`) + continue + } + if (!byPath.has(english)) { + problems.push(`${resourceOf('en.json')}: English metadata must resolve to source JSON, received ${english}`) + continue + } + const directory = dirname(english) + let sourceResources = true + for (const { filename, file } of resources) { + if (!byPath.has(file)) { + problems.push(`${resourceOf(filename)}: locale metadata must resolve to source JSON, received ${file}`) + sourceResources = false + } + if (dirname(file) !== directory) { + problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`) + } + } + if (sourceResources) { + const meta = readPluginMeta(specifier, parentURL) + if (meta?.error !== undefined) problems.push(meta.error) + } + for (const document of documents.filter(document => dirname(join(dir, document.file)) === directory)) { + const resource = resourceOf(basename(document.file)) + const file = lookup(resource) + if (file === undefined) { + problems.push(`${manifestPath}: exports must expose ${resource} (${document.file})`) + } else if (file !== join(dir, document.file)) { + problems.push(`${manifestPath}: exports for ${resource} must resolve to ${document.file}, received ${relative(dir, file)}`) + } + if (pkg.files !== undefined && !published(document.file, pkg.files)) { + problems.push(`${manifestPath}: files must include ${document.file}`) + } + } + } + + for (const document of documents) { + const requests = alternatives.get(document.file) + if (!document.file.split('/').includes('locale') && requests === undefined) continue + if (!document.metadata && !document.invalid) continue + if (claimed.has(join(dir, document.file))) continue + if (requests !== undefined && [...requests].some((resource) => { + const selected = lookup(resource) + return selected !== undefined && byPath.has(selected) + })) continue + problems.push(`${manifestPath}: exports must expose ${document.file} as a plugin locale resource with an en.json discovery baseline`) + } + return problems +} + +/** + * Check static plugin locale metadata and resource publication without evaluating plugin entries or reading built output. + * @param root - repository or fixture root. + * @returns diagnostics for an empty package corpus, invalid metadata, inaccessible resources, or omitted publication files. + */ +export function packageMetaProblems(root: string): string[] { + const manifests = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.replaceAll('\\', '/')) + if (manifests.length === 0) return [`${root}: no workspace package manifests found`] + return manifests.flatMap(path => packageProblems(join(root, path))) +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const problems = packageMetaProblems(resolve(import.meta.dirname, '..')) + if (problems.length > 0) { + console.error(problems.join('\n')) + process.exitCode = 1 + } else { + console.log('verify-package-meta: plugin locale metadata, resource exports, and publication files are valid.') + } +} From acec2ba713f31930833ea99d44a426506d085ea6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:52:21 +0800 Subject: [PATCH 07/10] docs(plugins): document PluginLocalizedMeta type name --- packages/util/package-manifest/README.i18n.yaml | 4 ++-- packages/util/package-manifest/README.md | 2 +- packages/util/package-manifest/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 3e100a15b0..dee2ad6293 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/package-manifest/README.md -README.md: 1edb4f802a3ceb4981f3c66e59a66d50edce22b5 -README.zh.md: 81af9eeb1a6504c5b19aa73509f69697c1726c6c +README.md: 86121432ba12329e111f2c796679ae019e403d7e +README.zh.md: d4d9d8b5b7787ac9531918fb1f7084c7c8a7a8d1 diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 1edb4f802a..86121432ba 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -52,7 +52,7 @@ The following metadata fields are optional. Omitting them leaves the format vers | `dsh.manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | | `engines.dsh` | Author-declared compatible DSH versions as a SemVer range, including exact prerelease versions. This field sits beside `engines.node` and `engines.npm`; an engines object may omit `dsh`. | -`LocalizedText` carries literal text or a language map with a required English fallback. `PackageMeta` carries optional display title and description fields, or a metadata diagnostic, for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. +`LocalizedText` carries literal text or a language map with a required English fallback. `PluginLocalizedMeta` carries optional display title and description fields, or a metadata diagnostic, for installed plugins. [App boot](../../boot/app-boot/README.md) reads these values; this package only supplies their types. Public composition declarations are defined in [`src/types.ts`](src/types.ts). Internal `configTrees`, `sessionFormatMigration`, and generated `moduleFallback` metadata remain owned by their image-packer, catalog, and launcher readers; the public types do not expose them. diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index 81af9eeb1a..d4d9d8b5b7 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -52,7 +52,7 @@ const manifest: DshPackageManifest = { | `dsh.manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | | `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围,也可填写精确的预发布版本。此字段与 `engines.node`、`engines.npm` 并列;engines 对象可省略 `dsh`。 | -`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PackageMeta` 携带已安装插件的可选展示标题与描述字段,或元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 +`LocalizedText` 携带字面文本或带必需英文回退值的语言映射。`PluginLocalizedMeta` 携带已安装插件的可选展示标题与描述字段,或元信息诊断。[App boot](../../boot/app-boot/README.zh.md) 读取这些值;本包只提供其类型。 公共组合声明定义在 [`src/types.ts`](src/types.ts) 中。内部 `configTrees`、`sessionFormatMigration` 和生成的 `moduleFallback` 元数据分别由镜像打包器、目录生成器和启动器读取方拥有;公共类型不暴露这些字段。 From a9f205c5843b7b2beec3ace1d13097fd711e1c9d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:52:21 +0800 Subject: [PATCH 08/10] refactor(plugins): rename PackageMeta to PluginLocalizedMeta --- packages/boot/app-boot/src/package-meta.ts | 4 ++-- .../app-boot/src/profile-resolution/service.ts | 4 ++-- packages/boot/plugin-manager/src/types.ts | 6 +++--- .../ui-plugin-manager/src/client/manager-store.ts | 6 +++--- packages/extensions/tool-cordis/src/api-catalog.ts | 14 +++++++------- packages/host/plugin-inventory/src/types.ts | 6 +++--- packages/util/package-manifest/src/index.ts | 2 +- packages/util/package-manifest/src/types.ts | 2 +- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index 30e6071f5b..cc8fe2ebfe 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -4,7 +4,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' -import type { LocalizedText, PackageMeta } from '@deepseek-ai/dsh-package-manifest' +import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import { barePackageName } from './profile-resolution/resolver.ts' const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u @@ -114,7 +114,7 @@ function localizedText( * @param parentURL - owning Loader tree's module-resolution base. * @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text. */ -export function readPluginMeta(specifier: string, parentURL: string): PackageMeta | undefined { +export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { if (barePackageName(specifier) === undefined) return undefined try { const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts index d5b3e1bec8..fad03a2582 100644 --- a/packages/boot/app-boot/src/profile-resolution/service.ts +++ b/packages/boot/app-boot/src/profile-resolution/service.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { join } from 'node:path' import { Service, type Context } from '@deepseek-ai/cordis' -import type { PackageMeta } from '@deepseek-ai/dsh-package-manifest' +import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import { barePackageName, installRuntimeInterception, @@ -110,7 +110,7 @@ export class PluginPackages extends Service { * @param parentURL - owning Loader tree's resolution base. * @returns local display metadata or its diagnostic; undefined for non-package requests or absent metadata. */ - metaOf(specifier: string, parentURL: string): PackageMeta | undefined { + metaOf(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { return readPluginMeta(specifier, parentURL) } } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index c80d9b5ab2..7743b4f35f 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -1,6 +1,6 @@ /** Public plugin management records shared with clients. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { PackageMeta } from '@deepseek-ai/dsh-package-manifest' +import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types' @@ -27,7 +27,7 @@ export interface BundleRowInfo { /** The module the row names. */ moduleName: string /** Local package display metadata, including rows whose bundle is disabled. */ - meta?: PackageMeta + meta?: PluginLocalizedMeta /** The Loader entry carrying this row, when exactly one live entry has its id. */ entryId?: PluginEntryId } @@ -37,7 +37,7 @@ export interface BundleInfo { name: string version?: string /** Local display text with available translations or literal fallbacks, or a metadata diagnostic. */ - meta?: PackageMeta + meta?: PluginLocalizedMeta /** Untranslated `description` of this bundle's package manifest. */ description?: string /** Selected in the profile manifest; a load error means its layer was skipped. */ 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 2113612733..e62b3bd913 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -27,7 +27,7 @@ import type { import { REGISTRY_URL } from '@deepseek-ai/dsh-plugin-manager/registry' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' -import type { LocalizedText, PackageMeta } from '@deepseek-ai/dsh-package-manifest' +import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' import type { ConfigLedger } from './config-ledger.ts' import { shortName } from './presentation.ts' @@ -61,7 +61,7 @@ export interface PackageRow { /** The module the row names. */ readonly moduleName: string /** Local package display text and metadata diagnostics supplied by the Host. */ - readonly meta?: PackageMeta + readonly meta?: PluginLocalizedMeta /** Whether the entry runs; false for a row without a live entry. */ readonly enabled: boolean /** The entry's fiber phase, null without a live fiber. */ @@ -76,7 +76,7 @@ export interface PackageView { readonly version?: string readonly description?: string /** Local package display text and metadata diagnostics supplied by the Host. */ - readonly meta?: PackageMeta + readonly meta?: PluginLocalizedMeta /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ readonly installed: boolean /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7035c5dc07..78975d4a43 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4281,11 +4281,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleInfo', - declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n meta?: PackageMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', + declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', }, { name: 'BundleRowInfo', - declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n meta?: PackageMeta;\n entryId?: PluginEntryId;\n}', + declaration: 'export interface BundleRowInfo {\n rowId: string;\n moduleName: string;\n meta?: PluginLocalizedMeta;\n entryId?: PluginEntryId;\n}', }, { name: 'ChangeResult', @@ -5311,10 +5311,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'OptionalSessionSeq', declaration: 'export type OptionalSessionSeq = SessionSeq | null;', }, - { - name: 'PackageMeta', - declaration: 'export interface PackageMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly error?: string;\n}', - }, { name: 'PackageResult', declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n}', @@ -5365,7 +5361,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginInventoryEntry', - declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: PackageMeta;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', + declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly meta?: PluginLocalizedMeta;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', + }, + { + name: 'PluginLocalizedMeta', + declaration: 'export interface PluginLocalizedMeta {\n readonly title?: LocalizedText;\n readonly description?: LocalizedText;\n readonly error?: string;\n}', }, { name: 'PluginRegistries', diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index f35322986d..b97ee6e8ee 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { PackageMeta } from '@deepseek-ai/dsh-package-manifest' +import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' /** Stable Loader-tree identity of one configured plugin entry. */ export type PluginEntryId = Branded<'PluginEntryId'> @@ -19,7 +19,7 @@ export interface PluginInventoryEntry { /** Exact module specifier imported by the Loader entry. */ readonly moduleName: string /** Local package display metadata, independent of whether the entry is enabled. */ - readonly meta?: PackageMeta + readonly meta?: PluginLocalizedMeta /** Effective Loader enablement, including disabled ancestor groups. */ readonly enabled: boolean readonly fiberPhase: PluginFiberPhase @@ -35,7 +35,7 @@ export interface AgentPresetPluginRow { /** Module specifier the row names. */ readonly moduleName: string /** Local package display metadata, independent of whether the preset is mounted. */ - readonly meta?: PackageMeta + readonly meta?: PluginLocalizedMeta /** * Effective enablement, including disabled ancestor groups. `'conditional'` * marks a `!!js` disabled expression on a composition no session has diff --git a/packages/util/package-manifest/src/index.ts b/packages/util/package-manifest/src/index.ts index 3be3802d88..ef056fada3 100644 --- a/packages/util/package-manifest/src/index.ts +++ b/packages/util/package-manifest/src/index.ts @@ -11,5 +11,5 @@ export type { DshPackageManifest, DshProfileManifest, LocalizedText, - PackageMeta, + PluginLocalizedMeta, } from './types.ts' diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index 96b3a29466..d6f161f97e 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -40,7 +40,7 @@ export interface DshManifest { export type LocalizedText = string | { readonly en: string; readonly [locale: string]: string } /** Validated plugin display fields or a diagnostic from reading exported locale files. */ -export interface PackageMeta { +export interface PluginLocalizedMeta { /** Display title; omission preserves the consumer's technical-name fallback. */ readonly title?: LocalizedText /** Display introduction after locale and package-field fallback. */ From 77bcc35adbf4a44006cecbe253295ab7cad0c3ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:52:09 +0800 Subject: [PATCH 09/10] fix(test): type migrated JSON without an unknown cast The merged concurrent migration test used an as-unknown widening assertion after JSON.parse. Master now rejects newly introduced unknown casts, so that test blocks the repository gate and unrelated PR validation. Give the map callback an explicit unknown return type instead. Keep the gate baseline unchanged; parsing and assertions have the same runtime behavior. --- scripts/migrate-sessions-to-v4.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/migrate-sessions-to-v4.spec.ts b/scripts/migrate-sessions-to-v4.spec.ts index e4735db329..cf52171fef 100644 --- a/scripts/migrate-sessions-to-v4.spec.ts +++ b/scripts/migrate-sessions-to-v4.spec.ts @@ -246,7 +246,7 @@ describe('one-time V4 migration command', () => { )) current.push(decoded) } - expect(current[0]!.toString().trim().split('\n').slice(1).map(line => JSON.parse(line) as unknown)).toMatchObject([ + expect(current[0]!.toString().trim().split('\n').slice(1).map((line): unknown => JSON.parse(line))).toMatchObject([ { type: 'subagent/catalog', data: { childId: 'z-child', childCreatedAt: 2, mode: 'continuable', label: 'saved child' } }, ]) outputs.push(current) From faa6986b2bcac95e535bc5ccf18d98801e56f3e1 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sat, 19 Sep 2026 21:53:43 +0800 Subject: [PATCH 10/10] docs(browser-use): use the runtime resolution vocabulary in the instance note The profile resolution lookup order note, merged today, owns the mechanism this note describes and names it runtime resolution with an interception layer at $DSH_HOME/profiles/node_modules. This note adopts that vocabulary and links the owning record instead of restating the resolution order. It also records the boundary the sibling experimental providers leave open: a profile that installs one of them beside this package hoists their dsh-mcp-client dependency into the profile's own node_modules, so this package's peer resolves there and the two copies keep separate serverName reservations. The scope split does not return, because none of them declares dsh-scope. Issue #4628 tracks moving those declarations to peers. Refs #4628 --- ...-09-18-profile-plugin-host-runtime-instances.i18n.yaml | 4 ++-- .../2026-09-18-profile-plugin-host-runtime-instances.md | 8 ++++---- ...2026-09-18-profile-plugin-host-runtime-instances.zh.md | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml index 3545277c9e..d8c7d77519 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md -2026-09-18-profile-plugin-host-runtime-instances.md: f8969b96627ba781fbac3ad45e3d92d62f1d4e78 -2026-09-18-profile-plugin-host-runtime-instances.zh.md: a42b50eff9a651abae7cff6d467ea210a2edc985 +2026-09-18-profile-plugin-host-runtime-instances.md: 76135318b3a935e4588c360df3210618a6c69216 +2026-09-18-profile-plugin-host-runtime-instances.zh.md: b2229574876b0ad326348ababd6b6fca6f3a1ac2 diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md index f8969b9662..76135318b3 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md +++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md @@ -6,13 +6,13 @@ English | [中文](2026-09-18-profile-plugin-host-runtime-instances.zh.md) ## Problem -A profile resolves a bare specifier against its own `node_modules` before the installation. A plugin the profile installs out-of-tree therefore receives its own copy of every package it reaches through a `dependencies` edge, while the Host that loads the plugin keeps the installation's copy. Most packages tolerate that. `@deepseek-ai/dsh-scope` does not: it tags a scoped context with a module-local `Symbol('dsh.scope')` and keeps scope parents and event-carrier keys in module-local tables, and every registry that reads a tag — tool registration, prompt sections, MCP resources, scoped events — compares it by identity. A profile copy's `createScope` writes a tag the installation's `scopeOf` cannot read, so a registration meant for one Agent lands in the process-global layer. +A plugin the profile installs out-of-tree resolves its bare specifiers along the ancestor `node_modules` chain, where the profile's own layer sits above the runtime resolution's interception layer ([lookup order](2026-09-19-profile-resolution-lookup-order.md)). It therefore receives its own copy of every package it reaches through a `dependencies` edge, while the Host that loads the plugin keeps the installation's copy. Most packages tolerate that. `@deepseek-ai/dsh-scope` does not: it tags a scoped context with a module-local `Symbol('dsh.scope')` and keeps scope parents and event-carrier keys in module-local tables, and every registry that reads a tag — tool registration, prompt sections, MCP resources, scoped events — compares it by identity. A profile copy's `createScope` writes a tag the installation's `scopeOf` cannot read, so a registration meant for one Agent lands in the process-global layer. Issue #4573 is that failure in production. While this package reached both runtimes through `dependencies`, a profile that mounted the Playwright MCP browser provider installed a second `dsh-scope`: the first Agent's MCP tools registered globally, the second Agent's tool synchronization collided, and `failOnStartupError: true` turned that collision into a rejected Agent creation. Matching the profile's package versions to the Host's did not change the outcome, because a duplicate exists at every version. ## Decision -An out-of-tree plugin package declares every host runtime whose cross-instance identity matters in matching `peerDependencies` and `devDependencies`, never in `dependencies`. Profile pnpm settings are `nodeLinker: hoisted` with `autoInstallPeers: false`, so such a peer is not installed into the profile and Node reaches the installation's single instance through the Harness home's module fallback. +An out-of-tree plugin package declares every host runtime whose cross-instance identity matters in matching `peerDependencies` and `devDependencies`, never in `dependencies`. Profile pnpm settings are `nodeLinker: hoisted` with `autoInstallPeers: false`, so such a peer is not installed into the profile, and the import reaches the installation's single instance through the runtime resolution's interception layer at `$DSH_HOME/profiles/node_modules` ([lookup order](2026-09-19-profile-resolution-lookup-order.md)). `@deepseek-ai/dsh-scope` carries that identity through `createScope` as well as through the readers `scopeOf`, `carrierKeyOf`, and `scopeTarget`: the writer mints the tag its readers compare. `@deepseek-ai/dsh-mcp-client` joins it because its live `serverName` reservations are module-local, so a second copy keeps a second reservation table for the same namespace. `@deepseek-ai/dsh-experimental-browser-use-runtime` therefore declares both as peers with matching dev entries and keeps `@deepseek-ai/schemastery` as its only runtime dependency, which carries no instance-local state. @@ -31,7 +31,7 @@ The [published dependency faces](../process/2026-08-26-published-dependency-face ## Consequences - A released fix reaches a profile when the plugin dependency is upgraded and reinstalled. An already-installed profile keeps its copies until then, and no Host-side change can repair it. -- The rule makes the plugin depend on the installation shipping the peer. Both packages are dependencies of the `@deepseek-ai/dsh` installation, so the fallback resolves them; a plugin outside the installation's closure would fail to load rather than start with a second copy. +- The rule makes the plugin depend on the installation shipping the peer. Both packages are dependencies of the `@deepseek-ai/dsh` installation, so the runtime resolution supplies them; a plugin outside the installation's closure would fail to load rather than start with a second copy. - `docs/module-graph.md` renders these edges in its peer section, so the graph states the ownership boundary that the sections encode. -- Three sibling experimental providers (`browser-use-stagehand-native`, `computer-use-cua-driver-mcp`, `computer-use-cua-driver-native`) declare `@deepseek-ai/dsh-mcp-client` as a dependency. They mount no per-Agent MCP client, so this decision leaves them unchanged; giving one of them a per-Agent mount requires moving that declaration to peers first. +- Three sibling experimental providers (`browser-use-stagehand-native`, `computer-use-cua-driver-mcp`, `computer-use-cua-driver-native`) declare `@deepseek-ai/dsh-mcp-client` as a dependency. They mount no per-Agent MCP client, so their own use does not reproduce this decision's failure. A profile that installs one of them beside this package hoists that dependency into the profile's own `node_modules`, above the interception layer, so this package's peer resolves to that copy: the two `dsh-mcp-client` instances keep separate `serverName` reservations, and a name reserved on one is not detected on the other. The scope split does not return, because none of them declares `@deepseek-ai/dsh-scope`. Issue #4628 tracks moving those three declarations to peers with their own verification. - `verify-package-dependencies` still selects no `packages/experimental/` package, so the sections of this package are pinned only by `tests/shared-host-runtimes.spec.ts`. Extending that gate's selection is the open coverage gap this note records. diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md index a42b50eff9..b222957487 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md @@ -6,13 +6,13 @@ Status: implemented ## Problem -Profile 解析裸模块名时,先看自己的 `node_modules`,再看安装。于是 profile 从 npm 装进来的 out-of-tree 插件,会为它经 `dependencies` 边到达的每个包各留一份自己的副本,而加载它的 Host 用的是安装里那一份。多数包可以容忍这种重复;`@deepseek-ai/dsh-scope` 不能:它用一个模块级 `Symbol('dsh.scope')` 给作用域上下文打标记,并把作用域父链和事件载体键保存在模块级表里,而每个读标记的注册表——工具注册、提示词 section、MCP 资源、作用域事件——都按同一性比较它。profile 那份副本的 `createScope` 写下的标记,安装那份的 `scopeOf` 读不到,于是本该属于某个 Agent 的注册落进了进程全局层。 +profile 从 npm 装进来的 out-of-tree 插件,沿祖先 `node_modules` 链解析裸模块名,而 profile 自己那一层位于运行时解析(runtime resolution)的拦截层之上([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。于是它会为经 `dependencies` 边到达的每个包各留一份自己的副本,而加载它的 Host 用的是安装里那一份。多数包可以容忍这种重复;`@deepseek-ai/dsh-scope` 不能:它用一个模块级 `Symbol('dsh.scope')` 给作用域上下文打标记,并把作用域父链和事件载体键保存在模块级表里,而每个读标记的注册表——工具注册、提示词 section、MCP 资源、作用域事件——都按同一性比较它。profile 那份副本的 `createScope` 写下的标记,安装那份的 `scopeOf` 读不到,于是本该属于某个 Agent 的注册落进了进程全局层。 Issue #4573 就是这个问题在生产里的形态。当本包经 `dependencies` 到达这两个运行时时,挂载 Playwright MCP 浏览器提供方的 profile 多装了一份 `dsh-scope`:第一个 Agent 的 MCP 工具注册到全局层,第二个 Agent 的工具同步撞名,`failOnStartupError: true` 把这次撞名放大成 Agent 创建失败。把 profile 的包版本对齐到 Host 并不能改变结果,因为无论哪个版本,副本都是第二份模块实例。 ## Decision -Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一律写成匹配的 `peerDependencies` 与 `devDependencies`,绝不写进 `dependencies`。Profile 的 pnpm 配置是 `nodeLinker: hoisted` 加 `autoInstallPeers: false`,这类 peer 不会装进 profile,Node 会经 Harness home 的模块 fallback 找到安装里的那一份实例。 +Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一律写成匹配的 `peerDependencies` 与 `devDependencies`,绝不写进 `dependencies`。Profile 的 pnpm 配置是 `nodeLinker: hoisted` 加 `autoInstallPeers: false`,这类 peer 不会装进 profile;导入走到 `$DSH_HOME/profiles/node_modules` 时,由运行时解析(runtime resolution)的拦截层提供安装里的那一份实例([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。 `@deepseek-ai/dsh-scope` 的同一性既在读取者 `scopeOf`、`carrierKeyOf`、`scopeTarget` 上,也在写入者 `createScope` 上:标记由写入者铸造,再由读取者比较。`@deepseek-ai/dsh-mcp-client` 同属这一类,因为它在线的 `serverName` 保留表是模块级的,第二份副本会为同一命名空间保留第二张表。因此 `@deepseek-ai/dsh-experimental-browser-use-runtime` 把两者声明为 peer(并补匹配的 dev 条目),只把不携带实例内状态的 `@deepseek-ai/schemastery` 留在运行时依赖里。 @@ -31,7 +31,7 @@ Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一 ## Consequences - 修复要生效,需要升级并重装 profile 里的插件依赖。在此之前,已装好的 profile 一直带着那份副本,Host 侧无法补救。 -- 这条规则让插件依赖「安装确实带了这个 peer」。两个包都是 `@deepseek-ai/dsh` 安装的依赖,fallback 能解析到;若某插件不在安装闭包内,它会直接加载失败,而不是带着第二份副本启动。 +- 这条规则让插件依赖「安装确实带了这个 peer」。两个包都是 `@deepseek-ai/dsh` 安装的依赖,运行时解析能提供它们;若某插件不在安装闭包内,它会直接加载失败,而不是带着第二份副本启动。 - `docs/module-graph.md` 把这些边画进 peer 区,图与分区表达的是同一条所有权边界。 -- 另有三个实验提供方(`browser-use-stagehand-native`、`computer-use-cua-driver-mcp`、`computer-use-cua-driver-native`)把 `@deepseek-ai/dsh-mcp-client` 写成依赖。它们不按 Agent 挂 MCP 客户端,本决策不动它们;若将来其中一个改成按 Agent 挂载,必须先把那条声明移到 peer。 +- 另有三个实验提供方(`browser-use-stagehand-native`、`computer-use-cua-driver-mcp`、`computer-use-cua-driver-native`)把 `@deepseek-ai/dsh-mcp-client` 写成依赖。它们不按 Agent 挂 MCP 客户端,因此自身不会触发本决策要防的故障。若一个 profile 把其中之一与本包一起安装,hoisted 会把那份依赖平铺到 profile 自己的 `node_modules`,位置高于拦截层,于是本包的 peer 解析到那份副本:两份 `dsh-mcp-client` 各存一张 `serverName` 保留表,一份上保留的名字在另一份上不再被检测。作用域标记的分裂不会回来,因为它们都没有把 `@deepseek-ai/dsh-scope` 写成依赖。Issue #4628 记录把这三处声明改为 peer 并各自验证的后续工作。 - `verify-package-dependencies` 仍不选择 `packages/experimental/` 下的包,所以本包的分区只由 `tests/shared-host-runtimes.spec.ts` 钉住。扩大该 gate 的选择范围,就是本记录留下的覆盖缺口。