Merge remote-tracking branch 'origin/feat/ripgrep-packaged-binary' into feat/pwsh-tool
# Conflicts: # apps/cli/package.json
This commit is contained in:
@@ -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/README.md
|
||||
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
|
||||
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462
|
||||
README.md: c8984bfa652a0ad7e12bc1f2001618df452bc863
|
||||
README.zh.md: 2a59a63d22cdb2e5c0de53cd1dcfce1296882c01
|
||||
|
||||
@@ -33,7 +33,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`cordis/`](cordis/README.md) | Cordis runtime integration: self-inspection/model-written temporary Plugins and restricted repository Plugin loading | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-projection/`](session-projection/README.md) | Projection seam: domain fold units serve whole values | Product — stable surface |
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定表面 |
|
||||
| [`timeout/`](timeout/README.md) | 工具调用超时策略:`tools/execute` 截止时间强制执行器 | 产品:稳定表面 |
|
||||
| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | 自指运行时工具集:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定表面 |
|
||||
| [`cordis/`](cordis/README.md) | Cordis 运行时集成:自检/模型编写的临时 Plugin,以及受限 repository Plugin 加载 | 产品:稳定表面 |
|
||||
| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定表面 |
|
||||
| [`session-persistence/`](session-persistence/README.md) | 持久化 seam + JSONL/SQLite 后端 | 产品:稳定表面 |
|
||||
| [`session-projection/`](session-projection/README.md) | 投影 seam:领域折叠单元供给全量值 | 产品:稳定表面 |
|
||||
|
||||
@@ -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/cordis/README.md
|
||||
README.md: a47b9ba20789bb6b9a36b1af9b3942b90e61b365
|
||||
README.zh.md: 3ee1ddb1db28352cd05b3e79e88228035bc39ac4
|
||||
README.md: 485a6ce7858a77507c07b76138127faa411b354b
|
||||
README.zh.md: 38bfcd9fcb50f608e83bafa34def5561a847c066
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# packages/cordis — the self-referential runtime toolset
|
||||
# packages/cordis — Cordis runtime integration
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model-facing tools over the live cordis runtime the agent itself runs inside: inspect the loaded plugins and service surface, mount model-written plugins, and dispose them again. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
|
||||
Plugins that integrate Harness-owned formats with the Cordis runtime: the self-referential model toolset and the restricted repository Plugin runtime.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`tool-cordis/`](tool-cordis/README.md) | The `cordis_inspect` / `cordis_mount` / `cordis_unmount` tools: read the current-process runtime and manage in-memory temporary Plugins under one owned group fiber | registers on `ctx.tools` |
|
||||
| [`repository-plugin/`](repository-plugin/README.md) | Prepare and mount static repository skills plus common `.mcp.json` servers through DSH-owned child Plugins | registers a Loader builtin |
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# packages/cordis:自指运行时工具集
|
||||
# packages/cordis:Cordis 运行时集成
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这些面向模型的工具作用于 agent(智能体)自身所在的实时 Cordis 运行时,可检查已加载的插件和服务接口、挂载模型编写的插件,并将其 dispose(资源释放)。设计说明见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。
|
||||
这些 Plugin 把 Harness 自有格式集成到 Cordis 运行时:包括自指的模型工具集,以及受限的 repository Plugin 运行时。
|
||||
|
||||
| 包(package) | 角色 | ctx 键 |
|
||||
|---|---|---|
|
||||
| [`tool-cordis/`](tool-cordis/README.md) | `cordis_inspect`/`cordis_mount`/`cordis_unmount` 工具:读取当前进程运行时,并在一个自有分组 fiber 下管理内存中的临时插件 | 注册到 `ctx.tools` |
|
||||
| [`repository-plugin/`](repository-plugin/README.md) | 通过 DSH 自有子 Plugin 准备并挂载静态 repository skills 与通用 `.mcp.json` server | 注册一个 Loader builtin |
|
||||
|
||||
6
packages/cordis/repository-plugin/README.i18n.yaml
Normal file
6
packages/cordis/repository-plugin/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md
|
||||
README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7
|
||||
README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797
|
||||
102
packages/cordis/repository-plugin/README.md
Normal file
102
packages/cordis/repository-plugin/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @deepseek-ai/dsh-repository-plugin
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Restricted repository Plugin format for DeepSeek Harness. A repository author declares static skill roots and an optional common `.mcp.json` in `.dsh-plugin/package.json`; the prepare helper copies those assets and emits a fixed import-free Cordis wrapper. The runtime wrapper can only delegate to this DSH-owned package, which composes [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [static repository Plugin format Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md).
|
||||
|
||||
## Authoring format
|
||||
|
||||
Place an ordinary package in the repository's `.dsh-plugin` directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "humanize-dsh-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "dsh-plugin-prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": ["../skills"],
|
||||
"mcpServers": "../.mcp.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
|
||||
|
||||
## Standalone app configuration
|
||||
|
||||
The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`):
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
|
||||
```
|
||||
|
||||
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
|
||||
|
||||
The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
|
||||
|
||||
## Preparation
|
||||
|
||||
`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point.
|
||||
|
||||
The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source.
|
||||
|
||||
## Runtime composition
|
||||
|
||||
Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates to that builtin with its own module URL and prepared manifest. The runtime validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped (a `files`/`.npmignore` mistake, a damaged cache entry) fails the plugin load instead of silently mounting a skill-less plugin. Repository skill roots mount as a uniquely named `dsh-skill-local` provider with default project/user roots excluded and watching disabled; cached package generations are immutable. Wrapper disposal removes the provider and all composed MCP clients through normal Cordis child-fiber teardown.
|
||||
|
||||
## Common MCP format
|
||||
|
||||
The `.mcp.json` root is `{ "mcpServers": { ... } }`. A stdio entry accepts only `type: "stdio"` (optional), `command`, `args`, and `env`; an HTTP entry accepts only `type: "http"`, `url`, and `headers`. String values support exact `${NAME}` process-environment expansion at Plugin load, and a missing name fails that load. HTTP URLs become the existing MCP client's `streamable-http` transport; stdio entries use the prepared package directory as `cwd`.
|
||||
|
||||
Unknown fields reject, including OAuth and `auth` objects. There is no `CLAUDE_PLUGIN_ROOT` expansion or compatibility layer. After translation, the existing `dsh-mcp-client` exclusively owns transport creation, connection diagnostics, tool synchronization, calls, and disconnect lifecycle; a network or child-process connection failure retains that client's established log-and-no-tools behavior.
|
||||
|
||||
## Export shape
|
||||
|
||||
Namespace Plugin: named exports `name` / `inject` / `apply`, preparation constants, and `prepareDshPlugin`; no default export. The package also exposes the `dsh-plugin-prepare` executable and an invariant companion.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Repository skills
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Indirectly through `dsh-tool-skill`: prepared, model-invocable skills join its logged catalog and selected instruction-body surface under their declared names and descriptions. The exact consumer schema is in the generated [`skill` tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill).
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional and data-dependent: each visible repository skill adds one capped catalog row; loading one adds its full current instruction body and resource-base guidance to retained tool history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
A stable prepared Plugin set is prefix-stable. Adding, removing, or replacing a repository Plugin can append the consumer's replacement catalog and affect later request prefixes.
|
||||
|
||||
### Repository MCP tools
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Indirectly through `dsh-mcp-client`: every connected server contributes its server-qualified tool schemas, and calls retain that client's canonical MCP results and rendering.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Conditional on successful connection and the remote tool list; schemas recur on requests in the active tool view, while calls and results remain in history until compaction.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Stable connected tool lists are prefix-stable. Plugin lifecycle or MCP tool-list changes can change later tool-schema prefixes from the first affected definition.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Skills and MCP only** — commands, hooks, agents, apps, arbitrary Cordis code, marketplaces, and compatibility shims are intentionally outside this format.
|
||||
- **No MCP authentication protocol** — static headers may use environment expansion, but OAuth-bearing definitions reject and private-server login flows are not implemented here.
|
||||
- **Generated assets are immutable runtime input** — repository cache generations are not watched; source, ref, path, or configuration must select another prepared generation.
|
||||
102
packages/cordis/repository-plugin/README.zh.md
Normal file
102
packages/cordis/repository-plugin/README.zh.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# @deepseek-ai/dsh-repository-plugin
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 DeepSeek Harness 的受限 repository Plugin 格式。仓库作者在 `.dsh-plugin/package.json` 中声明静态 skill 根和可选的通用 `.mcp.json`;prepare helper 会复制这些资源并生成固定、无 import 的 Cordis 包装模块。运行时包装模块只能委托给这个由 DSH 自有的包,再由它组合 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md)。设计依据见[静态 repository Plugin 格式 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。
|
||||
|
||||
## 创作格式
|
||||
|
||||
在仓库的 `.dsh-plugin` 目录中放置一个普通 package:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "humanize-dsh-plugin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "dsh-plugin-prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": ["../skills"],
|
||||
"mcpServers": "../.mcp.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。
|
||||
|
||||
## 独立应用配置
|
||||
|
||||
已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
|
||||
```
|
||||
|
||||
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。
|
||||
|
||||
TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
|
||||
|
||||
## 准备阶段
|
||||
|
||||
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。
|
||||
|
||||
外层 package manager 仍会运行已配置仓库 package 的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行 package-manager source 安装的仓库,它并不是安全边界。
|
||||
|
||||
## 运行时组合
|
||||
|
||||
加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装模块都把自身模块 URL 和已准备的 manifest 委托给该 builtin。运行时在挂载前会校验每个声明的 skill 根都是包内实际存在的目录——生成输出被丢弃的包(`files`/`.npmignore` 配置失误、缓存条目损坏)会使插件加载失败,而不是静默挂载一个没有 skill 的插件。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存 package generation 是不可变的。包装模块 dispose 时,会通过正常的 Cordis 子 fiber teardown 移除提供方和所有组合的 MCP client。
|
||||
|
||||
## 通用 MCP 格式
|
||||
|
||||
`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在 Plugin 加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的 package 目录作为 `cwd`。
|
||||
|
||||
未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期;网络或子进程连接失败沿用该 client 既有的“记录错误且不注册工具”行为。
|
||||
|
||||
## 导出形状
|
||||
|
||||
Namespace Plugin:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Repository skills
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基准指引加入保留的工具历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的已准备 Plugin 集合保持前缀稳定。添加、移除或替换 repository Plugin 可能使消费方追加替换目录,并影响后续请求前缀。
|
||||
|
||||
### Repository MCP 工具
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
取决于连接成功和远端工具列表;schema 会在对应工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
稳定的已连接工具列表保持前缀稳定。Plugin 生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。
|
||||
|
||||
## 已知限制与延后工作
|
||||
|
||||
- **仅支持 skills 与 MCP**:commands、hooks、agents、apps、任意 Cordis 代码、marketplace 和兼容 shim 均有意排除在该格式之外。
|
||||
- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。
|
||||
- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。
|
||||
55
packages/cordis/repository-plugin/package.json
Normal file
55
packages/cordis/repository-plugin/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-repository-plugin",
|
||||
"description": "Restricted repository plugin format and Cordis runtime for DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-plugin-prepare": "./lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-mcp-client": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
12
packages/cordis/repository-plugin/src/bin.ts
Normal file
12
packages/cordis/repository-plugin/src/bin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Command-line entry that prepares the current `.dsh-plugin` package. @module */
|
||||
|
||||
import { prepareDshPlugin } from './format.ts'
|
||||
|
||||
try {
|
||||
await prepareDshPlugin()
|
||||
} catch (error) {
|
||||
process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
193
packages/cordis/repository-plugin/src/format.ts
Normal file
193
packages/cordis/repository-plugin/src/format.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Static repository-plugin preparation and prepared-manifest validation.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { parseMcpDocument } from './mcp.ts'
|
||||
|
||||
/** Fixed module filename loaded from an installed prepared plugin package. */
|
||||
export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
|
||||
/** Fixed directory containing copied static plugin assets. */
|
||||
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
|
||||
/** Loader builtin used by every generated import-free wrapper. */
|
||||
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
|
||||
|
||||
const sourceMetadataSchema = z.object({
|
||||
skills: z.array(z.string().min(1)).default([]),
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, {
|
||||
message: 'declare at least one skill root or mcpServers file',
|
||||
})
|
||||
const sourcePackageSchema = z.looseObject({
|
||||
name: z.string().min(1),
|
||||
dsh: sourceMetadataSchema,
|
||||
})
|
||||
const preparedManifestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
skills: z.array(z.string().min(1)),
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict()
|
||||
const preparedConfigSchema = z.object({
|
||||
// Wrappers pass import.meta.url, which is always file: for an installed
|
||||
// package; any other scheme would only fail later inside fileURLToPath with
|
||||
// an uncontextualized TypeError, so reject it at this validation boundary.
|
||||
baseUrl: z.url({ protocol: /^file$/ }),
|
||||
manifest: preparedManifestSchema,
|
||||
}).strict()
|
||||
|
||||
/** Static manifest embedded in the generated wrapper. */
|
||||
export interface PreparedPluginManifest {
|
||||
name: string
|
||||
skills: string[]
|
||||
mcpServers?: string
|
||||
}
|
||||
|
||||
/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */
|
||||
export interface PreparedPluginConfig {
|
||||
baseUrl: string
|
||||
manifest: PreparedPluginManifest
|
||||
}
|
||||
|
||||
function formatZodError(label: string, error: z.ZodError): Error {
|
||||
return new Error(`${label}:\n${z.prettifyError(error)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the config passed by an installed prepared wrapper.
|
||||
* @param value - wrapper-provided value crossing the file/module boundary.
|
||||
* @returns a detached typed config.
|
||||
*/
|
||||
export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig {
|
||||
const result = preparedConfigSchema.safeParse(value)
|
||||
if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error)
|
||||
return {
|
||||
baseUrl: result.data.baseUrl,
|
||||
manifest: {
|
||||
name: result.data.manifest.name,
|
||||
skills: result.data.manifest.skills,
|
||||
...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `candidate` resolves outside `root` — the containment check shared
|
||||
* by prepare-time asset copying and runtime prepared-path resolution.
|
||||
* @param root - directory that must contain the candidate.
|
||||
* @param candidate - absolute path to test.
|
||||
* @returns true when the candidate escapes the root.
|
||||
*/
|
||||
export function isOutside(root: string, candidate: string): boolean {
|
||||
const path = relative(root, candidate)
|
||||
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
|
||||
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
|
||||
}
|
||||
|
||||
async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise<string> {
|
||||
if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`)
|
||||
let path: string
|
||||
try {
|
||||
path = await realpath(resolve(pluginDirectory, configured))
|
||||
} catch (cause) {
|
||||
throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause })
|
||||
}
|
||||
if (isOutside(sourceRoot, path)) {
|
||||
throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
const info = await stat(path)
|
||||
if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) {
|
||||
throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
// The manifest is static, so the wrapper's service dependencies are too:
|
||||
// declaring them gates the wrapper fiber until the composition provides
|
||||
// them, which means the runtime's SkillLocal/McpClient children activate
|
||||
// within the wrapper's own load epoch and their failures (duplicate
|
||||
// provider names, damaged packages) reject the wrapper's Loader
|
||||
// transaction instead of leaving a silently PENDING or FAILED child.
|
||||
const inject = [
|
||||
'loader',
|
||||
...manifest.skills.length > 0 ? ['skills'] : [],
|
||||
...manifest.mcpServers === undefined ? [] : ['tools'],
|
||||
]
|
||||
return [
|
||||
'// Generated by dsh-plugin-prepare. Do not edit.',
|
||||
`const manifest = ${JSON.stringify(manifest)}`,
|
||||
`export const name = ${JSON.stringify(manifest.name)}`,
|
||||
`export const inject = ${JSON.stringify(inject)}`,
|
||||
'export async function apply(ctx) {',
|
||||
` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
|
||||
` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
|
||||
' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
|
||||
* Outputs are staged and committed by rename, but the final publish (remove
|
||||
* old outputs, rename assets, rename entry) is not one atomic step: a crash
|
||||
* mid-publish can leave assets without an entry or neither. Rerunning prepare
|
||||
* repairs the package; partial outputs are never importable as a plugin.
|
||||
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
|
||||
* @returns the generated static manifest.
|
||||
*/
|
||||
export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> {
|
||||
const pluginDirectory = await realpath(resolve(directory))
|
||||
let packageValue: unknown
|
||||
try {
|
||||
packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
|
||||
}
|
||||
const parsed = sourcePackageSchema.safeParse(packageValue)
|
||||
if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error)
|
||||
|
||||
const sourceRoot = await realpath(dirname(pluginDirectory))
|
||||
const skillSources: string[] = []
|
||||
for (const configured of parsed.data.dsh.skills) {
|
||||
const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory')
|
||||
if (!isOutside(source, pluginDirectory)) {
|
||||
throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
skillSources.push(source)
|
||||
}
|
||||
let mcpSource: string | undefined
|
||||
if (parsed.data.dsh.mcpServers !== undefined) {
|
||||
mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file')
|
||||
parseMcpDocument(await readFile(mcpSource, 'utf8'))
|
||||
}
|
||||
|
||||
const manifest: PreparedPluginManifest = {
|
||||
name: parsed.data.name,
|
||||
skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`),
|
||||
...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` },
|
||||
}
|
||||
const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-'))
|
||||
try {
|
||||
const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY)
|
||||
await mkdir(join(stagedAssets, 'skills'), { recursive: true })
|
||||
await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
})))
|
||||
if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json'))
|
||||
await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest))
|
||||
|
||||
await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true })
|
||||
await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true })
|
||||
await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY))
|
||||
await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME))
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
145
packages/cordis/repository-plugin/src/index.ts
Normal file
145
packages/cordis/repository-plugin/src/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Restricted repository-plugin runtime for static skills and common MCP definitions.
|
||||
* @module @deepseek-ai/dsh-repository-plugin
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
isOutside,
|
||||
parsePreparedPluginConfig,
|
||||
type PreparedPluginConfig,
|
||||
} from './format.ts'
|
||||
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
|
||||
import {
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
} from './source.ts'
|
||||
|
||||
export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
prepareDshPlugin,
|
||||
type PreparedPluginManifest,
|
||||
} from './format.ts'
|
||||
|
||||
/** Cordis plugin name used by Loader diagnostics. */
|
||||
export const name = 'repository-plugin'
|
||||
/** Loader service required to register the fixed prepared-wrapper builtin. */
|
||||
export const inject = ['loader']
|
||||
|
||||
/** Repository Plugin runtime and source-list configuration. */
|
||||
export interface Config {
|
||||
/** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
|
||||
repositories?: string[]
|
||||
/** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
|
||||
cacheDir?: string
|
||||
}
|
||||
|
||||
export const Config = z.object({
|
||||
repositories: z.array(z.string().min(1)).default([]),
|
||||
cacheDir: z.string().min(1).optional(),
|
||||
}).strict().default({ repositories: [] })
|
||||
|
||||
function preparedPath(baseUrl: string, configured: string): string {
|
||||
if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
|
||||
const directory = dirname(fileURLToPath(baseUrl))
|
||||
const path = resolve(directory, configured)
|
||||
if (isOutside(directory, path)) {
|
||||
throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function preparedDirectory(baseUrl: string, configured: string): Promise<string> {
|
||||
const path = preparedPath(baseUrl, configured)
|
||||
// A manifest-declared skill root missing from the installed package (files/
|
||||
// .npmignore dropping generated outputs, a damaged cache entry) must fail
|
||||
// the plugin load: the skill provider treats an absent root as legitimately
|
||||
// empty, which would silently mount a skill-less plugin.
|
||||
let info
|
||||
try {
|
||||
info = await stat(path)
|
||||
} catch (cause) {
|
||||
throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause })
|
||||
}
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> {
|
||||
const config = parsePreparedPluginConfig(value)
|
||||
const directory = dirname(fileURLToPath(config.baseUrl))
|
||||
const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path)))
|
||||
const mcpConfigs = config.manifest.mcpServers === undefined
|
||||
? []
|
||||
: resolveMcpServers(
|
||||
parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
|
||||
process.env,
|
||||
directory,
|
||||
// Schemastery call signatures collapse the parameter to `never` under
|
||||
// NodeNext; ResolvedMcpServer is shaped for the Config union by design.
|
||||
).map(input => McpClient.Config(input as never))
|
||||
|
||||
await ctx.effect(async function* () {
|
||||
if (skillDirectories.length > 0) {
|
||||
const skills = ctx.plugin(SkillLocal, {
|
||||
providerName: `repository:${config.manifest.name}`,
|
||||
includeDefaultRoots: false,
|
||||
customSkillDirs: skillDirectories,
|
||||
watch: false,
|
||||
})
|
||||
await skills
|
||||
yield skills.dispose
|
||||
}
|
||||
for (const mcpConfig of mcpConfigs) {
|
||||
const mcp = ctx.plugin(McpClient, mcpConfig)
|
||||
await mcp
|
||||
yield mcp.dispose
|
||||
}
|
||||
}, `repository-plugin(${config.manifest.name})`)
|
||||
}
|
||||
|
||||
const preparedRuntime = {
|
||||
name: 'repository-plugin-runtime',
|
||||
apply: applyPrepared,
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers.
|
||||
* @param ctx - plugin context carrying the Loader service.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
|
||||
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
|
||||
}
|
||||
const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier)
|
||||
if (new Set(repositories).size !== repositories.length) {
|
||||
throw new Error('repository sources must resolve to unique exact specifiers')
|
||||
}
|
||||
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
|
||||
await ctx.effect(async function* () {
|
||||
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
|
||||
yield () => {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
|
||||
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
|
||||
}
|
||||
}
|
||||
for (const repository of repositories) {
|
||||
const plugin = await loadPreparedRepository(ctx, cache, repository)
|
||||
yield plugin.dispose
|
||||
}
|
||||
}, 'repository-plugin runtime and sources')
|
||||
}
|
||||
30
packages/cordis/repository-plugin/src/invariant.ts
Normal file
30
packages/cordis/repository-plugin/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`.
|
||||
* @module @deepseek-ai/dsh-repository-plugin/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'repository-plugin-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the package owns no service state; Loader fibers and the existing skill
|
||||
* and MCP owners expose the authoritative lifecycle relationships for its composed children.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
152
packages/cordis/repository-plugin/src/mcp.ts
Normal file
152
packages/cordis/repository-plugin/src/mcp.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Parser for the common `.mcp.json` file consumed by prepared repository plugins.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it:
|
||||
* the prepare bin must stay a zod-only module graph (no tools seam, no MCP
|
||||
* SDK). Exported so `repository-plugin.spec.ts` pins equality with the
|
||||
* client's exported pattern — prepare-time validation cannot drift from the
|
||||
* registry that enforces uniqueness.
|
||||
*/
|
||||
export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g
|
||||
|
||||
const stringMap = z.record(z.string(), z.string())
|
||||
const stdioServerSchema = z.object({
|
||||
type: z.literal('stdio').optional(),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: stringMap.optional(),
|
||||
}).strict()
|
||||
const httpServerSchema = z.object({
|
||||
type: z.literal('http'),
|
||||
url: z.string().min(1),
|
||||
headers: stringMap.optional(),
|
||||
}).strict()
|
||||
const documentSchema = z.object({
|
||||
mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])),
|
||||
}).strict()
|
||||
|
||||
/** One supported server entry from the common `.mcp.json` format. */
|
||||
export type McpServerDefinition = z.infer<typeof stdioServerSchema> | z.infer<typeof httpServerSchema>
|
||||
|
||||
/** Parsed common MCP document before process-environment expansion. */
|
||||
export interface McpDocument {
|
||||
mcpServers: Record<string, McpServerDefinition>
|
||||
}
|
||||
|
||||
/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */
|
||||
export type ResolvedMcpServer =
|
||||
| {
|
||||
transport: 'stdio'
|
||||
serverName: string
|
||||
command: string
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
cwd: string
|
||||
}
|
||||
| {
|
||||
transport: 'streamable-http'
|
||||
serverName: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
function assertTemplate(value: string, location: string): void {
|
||||
for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
|
||||
const name = match[1] as string
|
||||
if (!ENVIRONMENT_NAME_PATTERN.test(name)) {
|
||||
throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`)
|
||||
}
|
||||
}
|
||||
if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) {
|
||||
throw new Error(`${location} contains an unterminated environment placeholder`)
|
||||
}
|
||||
}
|
||||
|
||||
function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void {
|
||||
if ('command' in definition) {
|
||||
visit(definition.command, `mcpServers.${serverName}.command`)
|
||||
definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) })
|
||||
Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) })
|
||||
return
|
||||
}
|
||||
visit(definition.url, `mcpServers.${serverName}.url`)
|
||||
Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate one common `.mcp.json` document without resolving environment values.
|
||||
* @param content - UTF-8 JSON document.
|
||||
* @returns the supported stdio and Streamable HTTP server definitions.
|
||||
*/
|
||||
export function parseMcpDocument(content: string): McpDocument {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(content) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error('invalid .mcp.json: expected JSON', { cause })
|
||||
}
|
||||
const result = documentSchema.safeParse(value)
|
||||
if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`)
|
||||
for (const [serverName, definition] of Object.entries(result.data.mcpServers)) {
|
||||
if (!SERVER_NAME_PATTERN.test(serverName)) {
|
||||
throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match ${SERVER_NAME_PATTERN.source}`)
|
||||
}
|
||||
visitStrings(serverName, definition, assertTemplate)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string {
|
||||
return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
|
||||
const replacement = environment[name]
|
||||
if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`)
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
|
||||
function expandMap(values: Record<string, string> | undefined, environment: NodeJS.ProcessEnv, location: string): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [
|
||||
name,
|
||||
expand(value, environment, `${location}.${name}`),
|
||||
]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve supported MCP definitions to inputs for the existing MCP client.
|
||||
* @param document - validated common MCP document.
|
||||
* @param environment - process environment used for exact `${NAME}` expansion.
|
||||
* @param cwd - prepared plugin directory used for stdio child processes.
|
||||
* @returns one existing-client config input per declared server.
|
||||
*/
|
||||
export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] {
|
||||
return Object.entries(document.mcpServers).map(([serverName, definition]) => {
|
||||
if ('command' in definition) {
|
||||
return {
|
||||
transport: 'stdio',
|
||||
serverName,
|
||||
command: expand(definition.command, environment, `mcpServers.${serverName}.command`),
|
||||
args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)),
|
||||
env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`),
|
||||
cwd,
|
||||
}
|
||||
}
|
||||
const url = expand(definition.url, environment, `mcpServers.${serverName}.url`)
|
||||
const protocol = new URL(url).protocol
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`mcpServers.${serverName}.url must use http or https`)
|
||||
}
|
||||
return {
|
||||
transport: 'streamable-http',
|
||||
serverName,
|
||||
url,
|
||||
headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`),
|
||||
}
|
||||
})
|
||||
}
|
||||
94
packages/cordis/repository-plugin/src/source.ts
Normal file
94
packages/cordis/repository-plugin/src/source.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GitHub repository source validation and prepared-wrapper loading.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Context, Fiber, FiberState, Plugin } from 'cordis'
|
||||
import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { PREPARED_ENTRY_FILENAME } from './format.ts'
|
||||
|
||||
// Value mirror: Cordis's const enum has no runtime object to import. Keep
|
||||
// aligned with `packages/cordis/tool-cordis/src/fiber-state.ts`.
|
||||
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
|
||||
|
||||
/** Directory under the Harness home containing immutable repository generations. */
|
||||
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
|
||||
|
||||
// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config
|
||||
// parser, with the syntax the error message promises — instead of inside the
|
||||
// cache's pnpm install ('misconfiguration fails loud at the earliest
|
||||
// resolvable point').
|
||||
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
|
||||
|
||||
function validPluginPath(path: string): boolean {
|
||||
const segments = path.split('/').slice(1)
|
||||
return segments.length > 0
|
||||
&& segments.at(-1) === '.dsh-plugin'
|
||||
&& segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one user-facing GitHub source to the exact pnpm dependency specifier.
|
||||
* @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`.
|
||||
* @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted.
|
||||
* @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid.
|
||||
*/
|
||||
export function resolveRepositorySpecifier(configured: string): string {
|
||||
const match = GITHUB_SOURCE_PATTERN.exec(configured)
|
||||
if (match === null) {
|
||||
throw new Error(`repository source must use github:owner/repo#<ref> with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
const path = match[4]
|
||||
if (path !== undefined && !validPluginPath(path)) {
|
||||
throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`)
|
||||
}
|
||||
return path === undefined ? `${configured}&path:/.dsh-plugin` : configured
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the persistent repository cache root.
|
||||
* @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`.
|
||||
* @returns an absolute cache directory.
|
||||
*/
|
||||
export function resolveRepositoryCacheDirectory(configured: string | undefined): string {
|
||||
return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one exact repository generation's generated wrapper as a child Cordis fiber.
|
||||
* @param ctx - repository runtime context that owns the child.
|
||||
* @param cache - package-manager-native immutable repository cache.
|
||||
* @param specifier - normalized exact pnpm dependency specifier.
|
||||
* @returns the settled prepared-wrapper fiber.
|
||||
* @throws when installation, wrapper import, manifest validation, or child registration fails.
|
||||
*/
|
||||
export async function loadPreparedRepository(
|
||||
ctx: Context,
|
||||
cache: Pick<RepositoryCache, 'resolve'>,
|
||||
specifier: string,
|
||||
): Promise<Fiber> {
|
||||
const directory = await cache.resolve(specifier)
|
||||
const filename = join(directory, PREPARED_ENTRY_FILENAME)
|
||||
try {
|
||||
const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
|
||||
const fiber = ctx.plugin(plugin)
|
||||
await fiber
|
||||
// Awaiting a service-gated fiber returns while it is still PENDING (the
|
||||
// generated wrapper injects `skills`/`tools` per its manifest). This
|
||||
// runtime commits the repository configuration transactionally, so a
|
||||
// composition that never provides a required service must reject the
|
||||
// transaction here — not settle ACTIVE with a silently pending child.
|
||||
if (fiber.state !== FIBER_ACTIVE) {
|
||||
const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
|
||||
/* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */
|
||||
const detail = missing.join(', ') || 'unknown'
|
||||
throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`)
|
||||
}
|
||||
return await fiber
|
||||
} catch (cause) {
|
||||
throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause })
|
||||
}
|
||||
}
|
||||
117
packages/cordis/repository-plugin/tests/mcp-format.spec.ts
Normal file
117
packages/cordis/repository-plugin/tests/mcp-format.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SERVER_NAME_PATTERN as CLIENT_SERVER_NAME_PATTERN } from '@deepseek-ai/dsh-mcp-client'
|
||||
import { SERVER_NAME_PATTERN, parseMcpDocument, resolveMcpServers } from '../src/mcp.ts'
|
||||
|
||||
describe('repository plugin common .mcp.json support', () => {
|
||||
it('validates server names with exactly the pattern the MCP client registry enforces', () => {
|
||||
// mcp.ts restates the pattern to keep the prepare bin's module graph
|
||||
// zod-only; this pin is the drift guard.
|
||||
expect(SERVER_NAME_PATTERN.source).toBe(CLIENT_SERVER_NAME_PATTERN.source)
|
||||
expect(SERVER_NAME_PATTERN.flags).toBe(CLIENT_SERVER_NAME_PATTERN.flags)
|
||||
})
|
||||
|
||||
it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'expo',
|
||||
url: 'https://mcp.expo.dev/mcp',
|
||||
headers: {},
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
datajunction: {
|
||||
command: 'dj-mcp',
|
||||
args: ['--endpoint', '${DJ_API_URL}'],
|
||||
env: { DJ_API_URL: '${DJ_API_URL}' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{
|
||||
transport: 'stdio',
|
||||
serverName: 'datajunction',
|
||||
command: 'dj-mcp',
|
||||
args: ['--endpoint', 'http://localhost:8000'],
|
||||
env: { DJ_API_URL: 'http://localhost:8000' },
|
||||
cwd: '/plugin',
|
||||
}])
|
||||
})
|
||||
|
||||
it('fails loud when a declared environment value is absent', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } },
|
||||
}))
|
||||
|
||||
expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL')
|
||||
})
|
||||
|
||||
it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
local: { type: 'stdio', command: 'local-mcp' },
|
||||
remote: {
|
||||
type: 'http',
|
||||
url: 'http://${MCP_HOST}/mcp',
|
||||
headers: { Authorization: 'Bearer ${MCP_TOKEN}' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([
|
||||
{
|
||||
transport: 'stdio',
|
||||
serverName: 'local',
|
||||
command: 'local-mcp',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '/plugin',
|
||||
},
|
||||
{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'remote',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => {
|
||||
expect(() => parseMcpDocument('{')).toThrow('expected JSON')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { 'bad name': { command: 'server' } },
|
||||
}))).toThrow('server name')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { bad: { command: '${BAD-NAME}' } },
|
||||
}))).toThrow('unsupported environment placeholder')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { bad: { command: '${UNFINISHED' } },
|
||||
}))).toThrow('unterminated environment placeholder')
|
||||
const ftp = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } },
|
||||
}))
|
||||
expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https')
|
||||
})
|
||||
|
||||
it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => {
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
workiq: {
|
||||
type: 'http',
|
||||
url: 'https://workiq.microsoft.com/mcp',
|
||||
oauthClientId: 'client-id',
|
||||
oauthPublicClient: true,
|
||||
auth: { redirectPort: 3317 },
|
||||
},
|
||||
},
|
||||
}))).toThrow('invalid .mcp.json')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,457 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin'
|
||||
import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant'
|
||||
import { parsePreparedPluginConfig } from '../src/format.ts'
|
||||
import {
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
} from '../src/source.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function temporaryDirectory(name: string): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`))
|
||||
roots.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, name: string, dsh: Record<string, unknown>): Promise<string> {
|
||||
const directory = join(root, '.dsh-plugin')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('dsh-plugin-prepare', () => {
|
||||
it('copies declared static assets and emits the fixed import-free wrapper', async () => {
|
||||
const root = await temporaryDirectory('prepare')
|
||||
await writeSkill(join(root, 'skills'), 'repository-fixture')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' },
|
||||
},
|
||||
}))
|
||||
const directory = await writePlugin(root, 'fixture-plugin', {
|
||||
skills: ['../skills'],
|
||||
mcpServers: '../.mcp.json',
|
||||
})
|
||||
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({
|
||||
name: 'fixture-plugin',
|
||||
skills: ['dsh-plugin-assets/skills/0'],
|
||||
mcpServers: 'dsh-plugin-assets/.mcp.json',
|
||||
})
|
||||
const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')
|
||||
expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`)
|
||||
// Import-free means no static AND no dynamic imports; `import.meta.url`
|
||||
// (no whitespace, no call parenthesis) is the one allowed appearance.
|
||||
expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/)
|
||||
await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8'))
|
||||
.resolves.toContain('Static instructions.')
|
||||
await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8'))
|
||||
.resolves.toContain('mcp.expo.dev')
|
||||
})
|
||||
|
||||
it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => {
|
||||
const root = await temporaryDirectory('oauth')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
workiq: {
|
||||
type: 'http',
|
||||
url: 'https://workiq.microsoft.com/mcp',
|
||||
oauthClientId: 'client-id',
|
||||
oauthPublicClient: true,
|
||||
auth: { redirectPort: 3317 },
|
||||
},
|
||||
},
|
||||
}))
|
||||
const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' })
|
||||
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json')
|
||||
await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => {
|
||||
const malformedRoot = await temporaryDirectory('malformed-package')
|
||||
const malformed = join(malformedRoot, '.dsh-plugin')
|
||||
await mkdir(malformed)
|
||||
await writeFile(join(malformed, 'package.json'), '{')
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata')
|
||||
|
||||
const emptyRoot = await temporaryDirectory('empty-metadata')
|
||||
const empty = await writePlugin(emptyRoot, 'empty', {})
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file')
|
||||
|
||||
const missingRoot = await temporaryDirectory('missing-asset')
|
||||
const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist')
|
||||
|
||||
const absoluteRoot = await temporaryDirectory('absolute-asset')
|
||||
const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative')
|
||||
|
||||
const wrongTypeRoot = await temporaryDirectory('wrong-type')
|
||||
await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text')
|
||||
const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory')
|
||||
|
||||
const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type')
|
||||
await mkdir(join(wrongMcpRoot, 'not-a-file'))
|
||||
const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file')
|
||||
|
||||
const containingRoot = await temporaryDirectory('containing-root')
|
||||
const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package')
|
||||
|
||||
const escapedRoot = await temporaryDirectory('escaped-root')
|
||||
const outside = await temporaryDirectory('outside-root')
|
||||
await writeSkill(outside, 'outside-skill')
|
||||
const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root')
|
||||
})
|
||||
|
||||
it('validates prepared wrapper configs with and without MCP assets', () => {
|
||||
expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin')
|
||||
expect(parsePreparedPluginConfig({
|
||||
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
||||
})).toEqual({
|
||||
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepared repository plugin Loader composition', () => {
|
||||
it('mounts and removes copied skills through the real Loader and skill-local provider', async () => {
|
||||
const root = await temporaryDirectory('loader')
|
||||
await writeSkill(join(root, 'skills'), 'loaded-from-repository')
|
||||
const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
|
||||
|
||||
const id = await ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({
|
||||
name: 'loaded-from-repository',
|
||||
provider: 'repository:loader-fixture',
|
||||
content: 'Static instructions.',
|
||||
})
|
||||
|
||||
await ctx.loader.remove(id)
|
||||
await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined()
|
||||
await registrar.dispose()
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => {
|
||||
const root = await temporaryDirectory('mcp-loader')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { offline: { command: join(root, 'missing-mcp-command') } },
|
||||
}))
|
||||
const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
const id = await ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
||||
await ctx.loader.remove(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects hostile prepared paths before mounting children', async () => {
|
||||
const root = await temporaryDirectory('prepared-paths')
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
|
||||
for (const [filename, skillPath] of [
|
||||
['absolute.mjs', resolve(root)],
|
||||
['escaped.mjs', '../outside'],
|
||||
] as const) {
|
||||
const wrapper = join(root, filename)
|
||||
await writeFile(wrapper, [
|
||||
"export const inject = ['loader']",
|
||||
'export async function apply(ctx) {',
|
||||
` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`,
|
||||
` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`,
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path')
|
||||
}
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails the plugin load when a declared skill root is missing or not a directory', async () => {
|
||||
const root = await temporaryDirectory('missing-skill-root')
|
||||
await writeFile(join(root, 'not-a-directory'), 'text')
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
|
||||
for (const [filename, skillPath, message] of [
|
||||
['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'],
|
||||
['file.mjs', 'not-a-directory', 'skill root is not a directory'],
|
||||
] as const) {
|
||||
const wrapper = join(root, filename)
|
||||
await writeFile(wrapper, [
|
||||
"export const inject = ['loader']",
|
||||
'export async function apply(ctx) {',
|
||||
` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`,
|
||||
` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`,
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message)
|
||||
}
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered')
|
||||
|
||||
const replacement = { name: 'replacement', apply() {} }
|
||||
ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement
|
||||
await registrar.dispose()
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('configured GitHub repository sources', () => {
|
||||
it('defaults an omitted source list and rejects unknown configuration fields', () => {
|
||||
expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] })
|
||||
expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an empty direct-apply config', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await RepositoryPlugin.apply(ctx, {})
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => {
|
||||
expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0'))
|
||||
.toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin')
|
||||
expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin'))
|
||||
.toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')
|
||||
})
|
||||
|
||||
it('rejects absent refs and invalid plugin subpaths', () => {
|
||||
for (const source of [
|
||||
'github:owner/repository',
|
||||
'github:owner/repository#',
|
||||
'github:owner/repository#a#b',
|
||||
'https://github.com/owner/repository#ref',
|
||||
'github:owner/repository#ref&path:relative/.dsh-plugin',
|
||||
]) {
|
||||
expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#<ref>')
|
||||
}
|
||||
for (const path of [
|
||||
'/plugins//.dsh-plugin',
|
||||
'/plugins/../.dsh-plugin',
|
||||
'/plugins/./.dsh-plugin',
|
||||
'/plugins/not-a-plugin',
|
||||
]) {
|
||||
expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`))
|
||||
.toThrow('path must be an absolute repository subpath')
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => {
|
||||
const root = await temporaryDirectory('cache-root')
|
||||
vi.stubEnv('DSH_HOME', root)
|
||||
expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins'))
|
||||
expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit'))
|
||||
})
|
||||
|
||||
it('loads a configured source through the immutable cache and removes its skill on teardown', async () => {
|
||||
const root = await temporaryDirectory('configured-source')
|
||||
await writeSkill(join(root, 'skills'), 'configured-repository-skill')
|
||||
const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
const resolved: string[] = []
|
||||
const cacheDirectory = join(root, 'cache')
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) {
|
||||
expect(this.directory).toBe(cacheDirectory)
|
||||
resolved.push(specifier)
|
||||
return directory
|
||||
})
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
const registrar = ctx.plugin(RepositoryPlugin, {
|
||||
repositories: ['github:owner/repository#fixed-ref'],
|
||||
cacheDir: cacheDirectory,
|
||||
})
|
||||
await registrar
|
||||
expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin'])
|
||||
await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({
|
||||
provider: 'repository:configured-source-fixture',
|
||||
})
|
||||
|
||||
await registrar.dispose()
|
||||
await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('swaps generations on a live source-list update and rolls a failed candidate back', async () => {
|
||||
// The headline flow: a personal-config edit reaches this plugin as a
|
||||
// Loader entry.update, which restarts the row's fiber (old cleanup, then
|
||||
// new apply — so the 'already registered' builtin guard must not fire).
|
||||
const roots: Record<string, string> = {}
|
||||
for (const generation of ['one', 'two'] as const) {
|
||||
const root = await temporaryDirectory(`live-${generation}`)
|
||||
await writeSkill(join(root, 'skills'), `live-skill-${generation}`)
|
||||
const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory
|
||||
}
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => {
|
||||
const directory = roots[specifier]
|
||||
if (directory === undefined) throw new Error(`unprepared generation ${specifier}`)
|
||||
return directory
|
||||
})
|
||||
|
||||
// Route the row through the Loader builtin table exactly as a config tree
|
||||
// would; the module itself is the row's plugin.
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(Loader)
|
||||
await ctx2.plugin(SkillService)
|
||||
ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin
|
||||
const entryId = await ctx2.loader.create({
|
||||
name: 'cordis:repository-plugins',
|
||||
config: { repositories: ['github:owner/repository#one'] },
|
||||
})
|
||||
await ctx2.loader.await()
|
||||
await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' })
|
||||
|
||||
const entry = ctx2.loader.resolve(entryId)
|
||||
await entry.update({ config: { repositories: ['github:owner/repository#two'] } })
|
||||
await ctx2.loader.await()
|
||||
await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined()
|
||||
await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
|
||||
|
||||
// A failed candidate (unprepared source) rejects the update and the
|
||||
// transactional Loader restores the previous generation.
|
||||
await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } }))
|
||||
.rejects.toThrow('unprepared generation')
|
||||
await ctx2.loader.await()
|
||||
await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' })
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await expect(RepositoryPlugin.apply(ctx, {
|
||||
repositories: [
|
||||
'github:owner/repository#ref',
|
||||
'github:owner/repository#ref',
|
||||
],
|
||||
})).rejects.toThrow('must resolve to unique exact specifiers')
|
||||
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed'))
|
||||
await expect(RepositoryPlugin.apply(ctx, {
|
||||
repositories: ['github:owner/repository#other'],
|
||||
})).rejects.toThrow('prepare failed')
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a wrapper left pending by a composition without its required services', async () => {
|
||||
// A skills-declaring generation mounted where no skills service exists:
|
||||
// the wrapper fiber stays PENDING, and the transaction must fail loud
|
||||
// instead of committing an ACTIVE row over a silently inert child.
|
||||
const root = await temporaryDirectory('pending-services')
|
||||
await writeSkill(join(root, 'skills'), 'pending-service-skill')
|
||||
const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
// Deliberately NO SkillService.
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin'))
|
||||
.rejects.toMatchObject({
|
||||
message: expect.stringContaining('failed to load prepared repository Plugin') as string,
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('waiting for services: skills') as string,
|
||||
}) as Error,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('labels a missing prepared wrapper with its exact source and path', async () => {
|
||||
const root = await temporaryDirectory('missing-wrapper')
|
||||
const ctx = new Context()
|
||||
const specifier = 'github:owner/repository#missing&path:/.dsh-plugin'
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
|
||||
.rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository plugin invariant companion', () => {
|
||||
it('registers its explained empty invariant', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
33
packages/cordis/repository-plugin/tsconfig.json
Normal file
33
packages/cordis/repository-plugin/tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../mcp/mcp-client"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
packages/cordis/repository-plugin/tsdown.config.ts
Normal file
17
packages/cordis/repository-plugin/tsdown.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the runtime, invariant, and prepare executable as self-contained entries. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
])
|
||||
@@ -40,6 +40,13 @@ import {
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
/**
|
||||
* Normalize a POSIX-style test path to the platform separator: the sampler and
|
||||
* the workdir-relative display conversion group by `node:path.sep`, so
|
||||
* `/`-literal paths would collapse into per-path groups on Windows.
|
||||
*/
|
||||
const w = (path: string): string => path.replaceAll('/', sep)
|
||||
|
||||
/** One scripted collect-mode stream, returned by `readFrom(0)` after settlement. */
|
||||
interface ScriptedStream {
|
||||
text: string
|
||||
@@ -621,24 +628,24 @@ describe('raw output acquisition', () => {
|
||||
|
||||
describe('cross-directory sampling', () => {
|
||||
it('gives every top-level entry a slot before any entry gets a second', () => {
|
||||
const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f']
|
||||
const paths = ['v/a', 'v/b', 'v/c', 'v/d', 'src/e', 'guide/f'].map(w)
|
||||
// The head of 3 would be all `v/`; the sample reaches all three entries.
|
||||
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'], shown: 3, total: 3 })
|
||||
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['v/a', 'src/e', 'guide/f'].map(w), shown: 3, total: 3 })
|
||||
// Extra slots go round again — to the only entry with paths left — and the
|
||||
// page stays grouped by entry rather than interleaved.
|
||||
expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'], shown: 3, total: 3 })
|
||||
expect(sampleAcrossTopLevel(paths, 5)).toEqual({ items: ['v/a', 'v/b', 'v/c', 'src/e', 'guide/f'].map(w), shown: 3, total: 3 })
|
||||
})
|
||||
|
||||
it('hands an exhausted entry the remaining slots go to entries that still have paths', () => {
|
||||
const paths = ['solo/a', 'many/b', 'many/c', 'many/d']
|
||||
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'], shown: 2, total: 2 })
|
||||
const paths = ['solo/a', 'many/b', 'many/c', 'many/d'].map(w)
|
||||
expect(sampleAcrossTopLevel(paths, 3)).toEqual({ items: ['solo/a', 'many/b', 'many/c'].map(w), shown: 2, total: 2 })
|
||||
})
|
||||
|
||||
it('does not rescan exhausted entries while filling a skewed page', () => {
|
||||
const singletonCount = 12_500
|
||||
const paths = [
|
||||
...Array.from({ length: singletonCount }, (_, index) => `group-${index}/only`),
|
||||
...Array.from({ length: singletonCount }, (_, index) => `late/${index}`),
|
||||
...Array.from({ length: singletonCount }, (_, index) => `group-${index}${sep}only`),
|
||||
...Array.from({ length: singletonCount }, (_, index) => `late${sep}${index}`),
|
||||
]
|
||||
expect(sampleAcrossTopLevel(paths, paths.length - 1)).toMatchObject({
|
||||
shown: singletonCount + 1,
|
||||
@@ -648,15 +655,15 @@ describe('cross-directory sampling', () => {
|
||||
}, 500)
|
||||
|
||||
it('reports the entries it could not reach when the page is smaller than the top level', () => {
|
||||
const paths = ['a/1', 'b/1', 'c/1', 'd/1']
|
||||
expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'], shown: 2, total: 4 })
|
||||
const paths = ['a/1', 'b/1', 'c/1', 'd/1'].map(w)
|
||||
expect(sampleAcrossTopLevel(paths, 2)).toEqual({ items: ['a/1', 'b/1'].map(w), shown: 2, total: 4 })
|
||||
})
|
||||
|
||||
it('groups an absolute path by its first real name, not by its empty root segment', () => {
|
||||
// Paths outside the workdir stay absolute; without stripping the leading
|
||||
// separator every one of them would collapse into a single empty group.
|
||||
expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'], 2))
|
||||
.toEqual({ items: ['/out/a', '/away/c'], shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['/out/a', '/out/b', '/away/c', '/away/d'].map(w), 2))
|
||||
.toEqual({ items: ['/out/a', '/away/c'].map(w), shown: 2, total: 2 })
|
||||
})
|
||||
|
||||
it('reproduces the modification-time-ordered head for a flat result', () => {
|
||||
@@ -669,15 +676,15 @@ describe('cross-directory sampling', () => {
|
||||
'workspace/vendor/b.ts',
|
||||
'workspace/source/c.ts',
|
||||
'workspace/guides/d.md',
|
||||
], 3, 'workspace')).toEqual({
|
||||
items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'],
|
||||
].map(w), 3, 'workspace')).toEqual({
|
||||
items: ['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w),
|
||||
shown: 3,
|
||||
total: 3,
|
||||
})
|
||||
expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'], 2, '.'))
|
||||
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'], shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'], 2, '/'))
|
||||
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'], shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['./vendor/a.ts', './src/b.ts'].map(w), 2, '.'))
|
||||
.toEqual({ items: ['./vendor/a.ts', './src/b.ts'].map(w), shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['/vendor/a.ts', '/src/b.ts'].map(w), 2, w('/')))
|
||||
.toEqual({ items: ['/vendor/a.ts', '/src/b.ts'].map(w), shown: 2, total: 2 })
|
||||
const rooted = [
|
||||
['root', 'a', 'one'].join(sep),
|
||||
['root', 'a', 'two'].join(sep),
|
||||
@@ -685,8 +692,8 @@ describe('cross-directory sampling', () => {
|
||||
]
|
||||
expect(sampleAcrossTopLevel(rooted, 2, 'root'))
|
||||
.toEqual({ items: [rooted[0], rooted[2]], shown: 2, total: 2 })
|
||||
expect(sampleAcrossTopLevel(['other/a.ts'], 1, 'src'))
|
||||
.toEqual({ items: ['other/a.ts'], shown: 1, total: 1 })
|
||||
expect(sampleAcrossTopLevel(['other/a.ts'].map(w), 1, 'src'))
|
||||
.toEqual({ items: ['other/a.ts'].map(w), shown: 1, total: 1 })
|
||||
expect(sampleAcrossTopLevel(['src'], 1, 'src'))
|
||||
.toEqual({ items: ['src'], shown: 1, total: 1 })
|
||||
})
|
||||
@@ -767,9 +774,9 @@ describe('glob results', () => {
|
||||
// freshly-unpacked subtree first, and a head-of-3 reads like the entire
|
||||
// workspace. The sample reaches every top-level entry instead.
|
||||
const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } })
|
||||
subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].join('\n'))
|
||||
subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts', 'guide/e.md', 'top.txt'].map(w).join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })
|
||||
expect(text(result)).toBe('vendor/a.ts\nsrc/d.ts\nguide/e.md\n\n'
|
||||
expect(text(result)).toBe(['vendor/a.ts', 'src/d.ts', 'guide/e.md'].map(w).join('\n') + '\n\n'
|
||||
+ '(Showing 3 of 6 paths, sampled across 3 of the 4 top-level entries this pattern matched '
|
||||
+ 'instead of taken in modification-time order. Narrow path to inspect a specific subtree. '
|
||||
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
@@ -792,9 +799,9 @@ describe('glob results', () => {
|
||||
'workspace/vendor/b.ts',
|
||||
'workspace/source/c.ts',
|
||||
'workspace/guides/d.md',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*', path: 'workspace' }, { agent: agent('/w') })
|
||||
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
|
||||
].map(w).join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*', path: w('workspace') }, { agent: agent('/w') })
|
||||
expect(text(result)).toContain(['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w).join('\n'))
|
||||
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
|
||||
})
|
||||
|
||||
@@ -805,17 +812,17 @@ describe('glob results', () => {
|
||||
'/w/workspace/vendor/b.ts',
|
||||
'/w/workspace/source/c.ts',
|
||||
'/w/workspace/guides/d.md',
|
||||
].join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*', path: '/w/workspace' }, { agent: agent('/w') })
|
||||
expect(text(result)).toContain('workspace/vendor/a.ts\nworkspace/source/c.ts\nworkspace/guides/d.md')
|
||||
].map(w).join('\n'))
|
||||
const result = await call(ctx, 'glob', { pattern: '*', path: w('/w/workspace') }, { agent: agent(w('/w')) })
|
||||
expect(text(result)).toContain(['workspace/vendor/a.ts', 'workspace/source/c.ts', 'workspace/guides/d.md'].map(w).join('\n'))
|
||||
expect(text(result)).toContain('sampled across 3 of the 3 top-level entries')
|
||||
})
|
||||
|
||||
it('drops the narrowing hint when the sample reaches every top-level entry', async () => {
|
||||
const { ctx, subprocess } = await setup({ config: { globMaxResults: 3 } })
|
||||
subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].join('\n'))
|
||||
subprocess.handler = () => runResult(['vendor/a.ts', 'vendor/b.ts', 'vendor/c.ts', 'src/d.ts'].map(w).join('\n'))
|
||||
expect(text(await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/w') })))
|
||||
.toBe('vendor/a.ts\nvendor/b.ts\nsrc/d.ts\n\n'
|
||||
.toBe(['vendor/a.ts', 'vendor/b.ts', 'src/d.ts'].map(w).join('\n') + '\n\n'
|
||||
+ '(Showing 3 of 4 paths, sampled across 2 of the 2 top-level entries this pattern matched '
|
||||
+ 'instead of taken in modification-time order. '
|
||||
+ 'The complete result could not be saved; narrow pattern or path to see more.)')
|
||||
|
||||
@@ -244,8 +244,10 @@ describe('tool-str-replace-editor', () => {
|
||||
expect(listing).not.toContain('too-deep.txt')
|
||||
expect(listing).not.toContain('index.js')
|
||||
expect(listing).not.toContain('module.pyc')
|
||||
expect(listing).toContain('node_modules_old/kept.js')
|
||||
expect(listing).toContain('__pycache__backup/kept.py')
|
||||
// The listing carries absolute display paths; the POSIX-style substrings
|
||||
// only match on Linux, so assert with platform separators.
|
||||
expect(listing).toContain(join('node_modules_old', 'kept.js'))
|
||||
expect(listing).toContain(join('__pycache__backup', 'kept.py'))
|
||||
|
||||
const clipped = await setup({ maxOutputChars: 10 })
|
||||
await writeFile(join(clipped.root, 'large.txt'), 'x'.repeat(100))
|
||||
|
||||
@@ -261,7 +261,9 @@ function validateSnapshotTransition(
|
||||
* @returns stable identity used to reconcile a deferred change with its log event.
|
||||
*/
|
||||
export function goalChangeRef(change: GoalChangeMeta): GoalRef {
|
||||
return change.operation === 'clear' ? change.cleared : change.goal
|
||||
return change.operation === 'clear'
|
||||
? change.cleared
|
||||
: { id: change.goal.id, revision: change.goal.revision }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import GoalService, {
|
||||
foldGoal,
|
||||
renderGoalChange,
|
||||
} from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
import type { GoalChangeMeta, GoalChanged, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||||
|
||||
type DeferredInjection = UserMessage
|
||||
|
||||
@@ -381,6 +381,25 @@ describe('GoalService mutations', () => {
|
||||
expect(next.id).not.toBe(goal.id)
|
||||
})
|
||||
|
||||
it('emits bare compare-and-set refs in folded lastRef and goal/changed notifications', async () => {
|
||||
const { ctx, agent, session } = await harness()
|
||||
const seen: GoalChanged['ref'][] = []
|
||||
ctx.on('goal/changed', (_subject, change) => { seen.push(change.ref) })
|
||||
const created = ctx.goals.create(agent, { objective: 'bare refs', maxGoalRounds: 3 })
|
||||
const edited = ctx.goals.edit(agent, created, { objective: 'bare refs edited' })
|
||||
const blocked = ctx.goals.block(agent, edited, { code: 'bare-blocker', message: 'Bare refs.' })
|
||||
// GoalRef is exactly { id, revision }: every notification ref must be bare.
|
||||
for (const ref of seen) {
|
||||
expect(Object.keys(ref).sort()).toEqual(['id', 'revision'])
|
||||
expect(ref).toEqual({ id: created.id, revision: ref.revision })
|
||||
}
|
||||
expect(seen).toHaveLength(3)
|
||||
// The durable fold's lastRef is the same bare ref, not a full snapshot.
|
||||
const folded = foldGoal(session.events)
|
||||
expect(folded.lastRef).toEqual({ id: blocked.id, revision: blocked.revision })
|
||||
expect(Object.keys(folded.lastRef as object).sort()).toEqual(['id', 'revision'])
|
||||
})
|
||||
|
||||
it('keeps per-goal mutation timestamps monotonic when the wall clock moves backward', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(100)
|
||||
|
||||
@@ -61,11 +61,9 @@ export async function apply(ctx: Context): Promise<void> {
|
||||
// nothing is left to unmount or await then.
|
||||
const entry = ctx.loader.store[id]
|
||||
if (entry === undefined) return
|
||||
const fiber = entry.fiber
|
||||
ctx.loader.remove(id)
|
||||
// remove() only starts the fiber's dispose; join it so the chooser's
|
||||
// unload signals completion only after the backend quiesced.
|
||||
await fiber?.dispose()
|
||||
// remove() disposes the entry transactionally, so the chooser's unload
|
||||
// signals completion only after the backend quiesced.
|
||||
await ctx.loader.remove(id)
|
||||
}
|
||||
}, 'directory-picker-auto: backend entry')
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('real Loader composition', () => {
|
||||
const { ctx, configPath } = await loadComposition('127.0.0.1')
|
||||
|
||||
const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
||||
ctx.loader.remove(backendEntry.id)
|
||||
await ctx.loader.remove(backendEntry.id)
|
||||
const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
||||
await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
||||
expect(entryNames(ctx)).not.toContain(NATIVE)
|
||||
|
||||
@@ -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/webserver/README.md
|
||||
README.md: ace8c09e43dd8544a28d300f97b04610be78bc69
|
||||
README.zh.md: b9948e3d387a5da393ff62b9eeacfe310516f46a
|
||||
README.md: c3c7b222683bc7731a6c21f2fffd325225099bab
|
||||
README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db
|
||||
|
||||
@@ -6,7 +6,7 @@ Plain HTTP route-registration plugin (default-exported `HttpServerService`, conf
|
||||
|
||||
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
|
||||
|
||||
A listen failure (EADDRINUSE…) throws out of activation — a FAILED fiber the boot's fail-loud sweep reports. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
|
||||
|
||||
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
|
||||
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,使 fiber 进入 FAILED 状态并由启动流程的快速失败扫描报告。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的响应(SSE)不会自行结束。
|
||||
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。资源释放会把 `close()` 与 `closeAllConnections()` 配对,因为一直保持打开的 SSE(Server-Sent Events)响应不会自行结束。
|
||||
|
||||
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map,再统一发布,因此基线失败会保留先前的图。这样,即时重建不会消失在异步建立的监听基线中;重命名窗口会把路径标记为脏,保留最近一次成功基线,并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context, FiberState } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import HttpServer from '../src/index.ts'
|
||||
@@ -142,25 +142,17 @@ describe('real Loader composition', () => {
|
||||
const firstRoot = root
|
||||
root = undefined // keep the first composition's files until the end
|
||||
|
||||
// loader.await() never rejects (allSettled); the bind failure surfaces as
|
||||
// a FAILED fiber whose error escapes as a late rejection — the shape the
|
||||
// boot's installFailLoud is contracted to catch. Capture it here the same
|
||||
// way, and assert it really is the bind error.
|
||||
const rejections: unknown[] = []
|
||||
const onUnhandled = (err: unknown): void => { rejections.push(err) }
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
let second: Context | undefined
|
||||
try {
|
||||
second = await loadComposition(takenPort)
|
||||
const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
|
||||
expect(entry?.fiber?.state).toBe(FiberState.FAILED)
|
||||
// The rejection escapes a tick after loader.await() settles; bounded poll.
|
||||
for (let i = 0; i < 100 && rejections.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
let failure: unknown
|
||||
try {
|
||||
await loadComposition(takenPort)
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
|
||||
second = context
|
||||
expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/)
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
await second?.fiber.dispose()
|
||||
context = first
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
|
||||
@@ -36,8 +36,11 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000
|
||||
/**
|
||||
* Valid `serverName`: 1–32 chars of `[A-Za-z0-9_-]`. Kept well under the
|
||||
* 64-char public-name budget so typical raw tool names survive unhashed.
|
||||
* Exported so upstream producers of Config inputs (repository-plugin's
|
||||
* `.mcp.json` prepare-time validation) reject the same names this registry
|
||||
* would.
|
||||
*/
|
||||
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
|
||||
/**
|
||||
* Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
|
||||
|
||||
@@ -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/session-query/tool-session-query/README.md
|
||||
README.md: 9a70f29d7c39af816c9efcf479ad129f0148883c
|
||||
README.zh.md: 55717aef20d53686cce963d09b2e41350d274a75
|
||||
README.md: d973daf1124c4be05f7335b18661d431d45be39f
|
||||
README.zh.md: b27d79a905a029d3750f24573e3c32785a314015
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; the shipped TUI, Web, and headless compositions mount it by default, while ACP does not.
|
||||
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已交付的 TUI、Web 与无头组合默认挂载它,而 ACP(Agent Client Protocol)不挂载。
|
||||
位于 `ctx.sessionQuery` 之上、经工作区授权的模型工具。该 opt-in 包(package)只依赖统一接口,并注册 `session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`;已发布的宿主组合默认不挂载它。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -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/skill/skill-local/README.md
|
||||
README.md: 2077cf852fe90f7a0fec4e9bda1e9ff68fc56453
|
||||
README.zh.md: ba1c71f1bc1916daad82d872ae6658bb203133c9
|
||||
README.md: f85cc2e6fd0c32cb88f28a2914a03e22b3a20657
|
||||
README.zh.md: 73a66831ad14b7edb346227cf6adb52ec8247fd7
|
||||
|
||||
@@ -14,6 +14,8 @@ Requires `ctx.skills` (`inject: ['skills']`).
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `providerName` | `local` | Unique name used to register this provider on `ctx.skills`. |
|
||||
| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md); scans `skills` under this directory. |
|
||||
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
|
||||
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
|
||||
@@ -36,7 +38,7 @@ Default roots are resolved in this provider's rank order:
|
||||
| 400 | `user-dsh` | `<dshHome>/skills` |
|
||||
| 500 | `user-agents` | `<agentsHome>/skills` |
|
||||
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. This provider supplies project and user skills; another provider may supply built-in system skills.
|
||||
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers such as immutable repository Plugins to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills.
|
||||
|
||||
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion.
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
| 字段 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `providerName` | `local` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 |
|
||||
| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 |
|
||||
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 |
|
||||
| `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 |
|
||||
| `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 |
|
||||
@@ -36,7 +38,7 @@
|
||||
| 400 | `user-dsh` | `<dshHome>/skills` |
|
||||
| 500 | `user-agents` | `<agentsHome>/skills` |
|
||||
|
||||
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。
|
||||
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变 repository Plugin。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。
|
||||
|
||||
当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;格式错误或非文本条目会警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export const inject = ['skills']
|
||||
|
||||
/** Local filesystem skill provider configuration. */
|
||||
export interface Config {
|
||||
/** Unique provider name. Defaults to `local`. */
|
||||
providerName?: string
|
||||
/** Whether project and user roots are included around custom roots. */
|
||||
includeDefaultRoots?: boolean
|
||||
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
|
||||
@@ -65,11 +69,13 @@ export interface Config {
|
||||
watchMaxProjects?: number
|
||||
/** Whether watched symbolic links follow their target files. */
|
||||
watchFollowSymlinks?: boolean
|
||||
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
|
||||
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */
|
||||
bundledSkillDir?: string
|
||||
}
|
||||
|
||||
export const Config: Schema<Config> = z.object({
|
||||
providerName: z.string().min(1).default('local'),
|
||||
includeDefaultRoots: z.boolean().default(true),
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
customSkillDirs: z.array(z.string()).default([]),
|
||||
@@ -138,7 +144,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
|
||||
/** Provider that maps local project/user skill roots into `ctx.skills`. */
|
||||
export class LocalSkillProvider implements SkillProvider {
|
||||
readonly name = 'local'
|
||||
readonly name: string
|
||||
private readonly includeDefaultRoots: boolean
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly customSkillDirs: string[]
|
||||
@@ -151,12 +158,19 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
control: SkillProviderControl,
|
||||
config: Config = {},
|
||||
) {
|
||||
this.name = config.providerName ?? 'local'
|
||||
this.includeDefaultRoots = config.includeDefaultRoots ?? true
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
|
||||
control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
|
||||
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
|
||||
// The environment bundled root is a default root: an isolated provider
|
||||
// (includeDefaultRoots: false — repository plugins) must see only its
|
||||
// explicit custom roots, or every such provider would re-discover the
|
||||
// app's bundled skills and claim them under its own provider name.
|
||||
const bundledSkillDir = config.bundledSkillDir
|
||||
?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined)
|
||||
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
|
||||
}
|
||||
|
||||
@@ -177,7 +191,7 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
}
|
||||
const candidates: SkillCandidate[] = []
|
||||
for (const root of roots) {
|
||||
for (const skill of await discoverRoot(root, this.ctx)) {
|
||||
for (const skill of await discoverRoot(root, this.ctx, this.name)) {
|
||||
candidates.push(skill)
|
||||
}
|
||||
}
|
||||
@@ -227,21 +241,23 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
|
||||
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
|
||||
const roots: SkillRoot[] = []
|
||||
if (cwd !== undefined) {
|
||||
if (this.includeDefaultRoots && cwd !== undefined) {
|
||||
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
|
||||
roots.push(
|
||||
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot },
|
||||
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot },
|
||||
)
|
||||
}
|
||||
roots.push(
|
||||
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
|
||||
...this.bundledSkillDir === undefined
|
||||
? []
|
||||
: [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }],
|
||||
)
|
||||
roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })))
|
||||
if (this.includeDefaultRoots) {
|
||||
roots.push(
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
|
||||
)
|
||||
}
|
||||
if (this.bundledSkillDir !== undefined) {
|
||||
roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true })
|
||||
}
|
||||
return roots
|
||||
}
|
||||
}
|
||||
@@ -693,7 +709,7 @@ function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
||||
}
|
||||
|
||||
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
|
||||
async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Promise<SkillCandidate[]> {
|
||||
const skills: SkillCandidate[] = []
|
||||
const entries = await listSkillRootEntries(root, ctx)
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
@@ -711,7 +727,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
invocation: parsed.invocation,
|
||||
provider: 'local',
|
||||
provider,
|
||||
source: root.source,
|
||||
rank: root.rank,
|
||||
locator,
|
||||
|
||||
@@ -820,6 +820,22 @@ describe('LocalSkillProvider', () => {
|
||||
await ctx.plugin(SkillLocal, { watch: false })
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
|
||||
|
||||
// Isolated providers see only their explicit roots: the environment
|
||||
// bundled root is a default root, so includeDefaultRoots: false must
|
||||
// drop it — repository providers never re-claim the app's builtins.
|
||||
const isolated = new Context()
|
||||
await isolated.plugin(SkillService)
|
||||
const customOnly = join(envHome, 'custom-only')
|
||||
await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill')
|
||||
await isolated.plugin(SkillLocal, {
|
||||
providerName: 'isolated',
|
||||
includeDefaultRoots: false,
|
||||
customSkillDirs: [customOnly],
|
||||
watch: false,
|
||||
})
|
||||
expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill'])
|
||||
await isolated.fiber.dispose()
|
||||
|
||||
process.env.DSH_HOME = join(envHome, 'empty-dsh')
|
||||
delete process.env.DSH_BUNDLED_SKILL_DIR
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
|
||||
|
||||
@@ -147,12 +147,12 @@ describe('typert loader', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(ctx.typert.list()).toHaveLength(1)
|
||||
|
||||
ctx.loader.remove(id)
|
||||
await ctx.loader.remove(id)
|
||||
await ctx.loader.await()
|
||||
// The unmount reconciliation rides a queued microtask flush.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined()
|
||||
ctx.loader.remove(plainId)
|
||||
await ctx.loader.remove(plainId)
|
||||
await ctx.loader.await()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
|
||||
|
||||
@@ -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/ui/app-boot/README.md
|
||||
README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e
|
||||
README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5
|
||||
README.md: 2eb9e904d574df39b0884558fc0a53f9dc04cdc1
|
||||
README.zh.md: 78bd99943fcadebf42a5d772d49f3bfcf6a8790a
|
||||
|
||||
@@ -8,17 +8,19 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `installFailLoud(binName, proc?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure |
|
||||
| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Mount the statically imported Include builtin and retain the exact root entry used by personal-config HMR |
|
||||
| `watchPersonalPatches(ctx, options)` | Register `$DSH_HOME/config.yaml` with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current personal overlay) and returns an async disposer |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
|
||||
@@ -26,11 +28,13 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Personal config
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI, Web, and headless surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
The TUI and Web keep `config.yaml` live through `watchPersonalPatches`; one-shot headless runs read only the startup value. The watcher targets the exact personal path even when the file or immediate parent does not exist, serializes bursts, and recomposes the personal patches inside the caller's layer order (surface overlay below, app-generated patches above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -8,17 +8,19 @@
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `installFailLoud(binName, proc?)` | 将启动期或后续未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 |
|
||||
| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
|
||||
| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 挂载静态导入的 Include builtin,并保留个人配置 HMR(热模块替换)使用的确切根配置项 |
|
||||
| `watchPersonalPatches(ctx, options)` | 向现有 Cordis HMR 服务注册 `$DSH_HOME/config.yaml`;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前个人 overlay)以事务方式重新组合完整 patch 列表,并返回异步 disposer |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject |
|
||||
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
Loader 结算会在导入或生命周期失败时 reject,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
@@ -26,11 +28,13 @@ Loader 树结算不会向调用方传播两类故障,因此需要分别保护
|
||||
|
||||
## 个人配置
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI、Web 和无头界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
TUI 和 Web 会持续应用 `config.yaml` 的变更,具体由 `watchPersonalPatches` 负责;一次性无头运行只读取启动时的值。即使该文件或其直接父目录不存在,watcher 仍会监视确切的个人配置路径;它会串行处理突发变更,并按调用方的层次顺序重新组合个人 patch(surface overlay 在下、应用生成的 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离 observer 失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-hmr": "^1.0.15",
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
@@ -37,9 +38,16 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cordisjs/plugin-hmr": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-hmr": "workspace:^",
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -11,9 +11,10 @@ import { readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context, type FiberState } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader'
|
||||
import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type {} from '@cordisjs/plugin-hmr'
|
||||
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
@@ -67,6 +68,8 @@ export function loadEnv(
|
||||
/** File inside the Harness home holding the personal loader overlay patches. */
|
||||
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
|
||||
|
||||
const bootstrapIncludes = new WeakMap<Context, Entry>()
|
||||
|
||||
// The include's YAML dialect (`!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time), imported
|
||||
// from the include itself so patch parsing and config dumping can never drift
|
||||
@@ -287,6 +290,99 @@ function groupedDump(
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/** Options for live personal-config reconciliation. */
|
||||
export interface PersonalPatchWatchOptions {
|
||||
/** Diagnostic prefix used by {@link loadPersonalPatches}. */
|
||||
binName: string
|
||||
/** Harness home containing `config.yaml`; defaults to {@link resolveDshHome}. */
|
||||
dir?: string
|
||||
/**
|
||||
* Compose the full patch list for a fresh personal-overlay generation —
|
||||
* the same composition the app booted with, so a reload can interleave the
|
||||
* new personal patches between app-owned layers (surface overlay below,
|
||||
* profile/flag patches above). Identity when omitted: the personal overlay
|
||||
* is the whole patch list.
|
||||
*/
|
||||
compose?: (personalPatches: PatchOptions[]) => PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the personal overlay through Cordis HMR and transactionally reapply it to the boot include.
|
||||
* @param ctx - settled app context containing the root Include and an active HMR service.
|
||||
* @param options - diagnostic, Harness-home, and patch-composition inputs.
|
||||
* @returns an asynchronous disposer after the exact-path watcher is ready.
|
||||
* @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
|
||||
*/
|
||||
export async function watchPersonalPatches(
|
||||
ctx: Context,
|
||||
options: PersonalPatchWatchOptions,
|
||||
): Promise<() => Promise<void>> {
|
||||
const { binName, dir = resolveDshHome(), compose = (patches: PatchOptions[]) => patches } = options
|
||||
const hmr = ctx.get('hmr')
|
||||
if (hmr === undefined) throw new Error(`${binName}: personal config watching requires the Cordis HMR service`)
|
||||
const entry = bootstrapIncludes.get(ctx)
|
||||
if (entry === undefined) throw new Error(`${binName}: personal config watching requires the root Include entry`)
|
||||
const filename = join(dir, PERSONAL_CONFIG_FILENAME)
|
||||
const register = hmr.registerConfig(filename, async () => {
|
||||
// Re-read the include's non-patch options per refresh: a writer that
|
||||
// updates the root Include's other options between refreshes (none exists
|
||||
// today) must not have them silently reverted by a personal reload.
|
||||
const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
|
||||
const personalPatches = loadPersonalPatches(binName, dir) ?? []
|
||||
const patches = compose(personalPatches)
|
||||
await entry.update({
|
||||
config: {
|
||||
...includeConfig,
|
||||
patches,
|
||||
},
|
||||
})
|
||||
})
|
||||
try {
|
||||
return await register
|
||||
} catch (error) {
|
||||
// A surface can dispose the whole tree while the watcher is still opening
|
||||
// (a TUI `/exit` typed during startup): the HMR effect registration then
|
||||
// fails with INACTIVE_EFFECT. That is the app exiting exactly as asked,
|
||||
// not a watch failure — return a no-op disposer instead of crashing.
|
||||
if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount and remember the exact root Include entry used by app boot and personal-config HMR.
|
||||
* @param ctx - context carrying an initialized Loader service.
|
||||
* @param absoluteConfigPath - absolute YAML or JSON configuration path.
|
||||
* @param patches - initial app and personal patches, applied in order.
|
||||
* @returns the created root Include entry, or `undefined` when a surface
|
||||
* disposed the whole tree (taking the Loader service with it) while the
|
||||
* transactional create was still settling entry lifecycle.
|
||||
*/
|
||||
export async function mountRootInclude(
|
||||
ctx: Context,
|
||||
absoluteConfigPath: string,
|
||||
patches: readonly PatchOptions[] = [],
|
||||
): Promise<Entry | undefined> {
|
||||
ctx.loader.builtins.include = Include
|
||||
// Pinned id: the bootstrap include is app glue, not a config row, and its
|
||||
// id appears in Loader failure chains — a random id would make startup
|
||||
// diagnostics unstable across runs (and snapshot fixtures).
|
||||
const rootInclude: EntryOptions = {
|
||||
id: 'include',
|
||||
name: 'cordis:include',
|
||||
config: {
|
||||
path: pathToFileURL(absoluteConfigPath).href,
|
||||
...patches.length > 0 ? { patches: [...patches] } : {},
|
||||
},
|
||||
}
|
||||
const includeId = await ctx.loader.create(rootInclude)
|
||||
const loader = ctx.get('loader')
|
||||
if (loader === undefined) return undefined
|
||||
const entry = loader.resolve(includeId)
|
||||
bootstrapIncludes.set(ctx, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
||||
* exercise the handler without registering on (or exiting) the real process.
|
||||
@@ -430,12 +526,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* `cordis:include` builtin, loading through the ambient module pipeline
|
||||
* (vite/tsx/plain ESM) while the included tree's own specifiers stay
|
||||
* config-relative. The package build embeds Include while leaving Loader
|
||||
* external, so the built include tree and host share one Loader peer. A
|
||||
* missing fiber rejects here; a later init rejection is rethrown with its
|
||||
* original stack by {@link assertEntriesActivated}; later unhandled
|
||||
* rejections remain covered by {@link installFailLoud}. Built bins need the
|
||||
* Loader's native helper for bare plugin specifiers; relative specifiers do
|
||||
* not.
|
||||
* external, so the built include tree and host share one Loader peer. Loader
|
||||
* settlement rejects startup failures, which `boot` wraps after disposing the
|
||||
* partial context; a missing fiber or never-activating entry is rejected by
|
||||
* the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
|
||||
* init rejection with its original stack; later unhandled rejections remain
|
||||
* covered by {@link installFailLoud}. Built bins need the Loader's native
|
||||
* helper for bare plugin specifiers; relative specifiers do not.
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
@@ -444,6 +541,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
|
||||
* @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts.
|
||||
* @returns the root context once every entry has started, or as soon as a
|
||||
* surface disposed the tree while startup was still in flight.
|
||||
* @throws a labelled error after disposing the partial context — `host
|
||||
* preparation failed` when `prepare` threw before any config-tree entry
|
||||
* mounted, `plugin tree failed to load` afterwards.
|
||||
*/
|
||||
export async function boot(
|
||||
binName: string,
|
||||
@@ -452,28 +552,44 @@ export async function boot(
|
||||
prepare?: (ctx: Context) => Promise<void> | void,
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await prepare?.(ctx)
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: {
|
||||
path: pathToFileURL(absoluteConfigPath).href,
|
||||
...patches !== undefined && patches.length > 0 ? { patches } : {},
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
// A surface can finish and dispose the whole tree while that await is still
|
||||
// pending: the TUI renders as soon as its own fiber starts, so an `/exit`
|
||||
// typed before the last entry settles tears the context down under us. The
|
||||
// Loader service goes with it, and the activation audit describes a live
|
||||
// tree — reading `ctx.loader` here would throw a TypeError over an app that
|
||||
// exited exactly as asked.
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
return ctx
|
||||
// Two failure labels: `prepare` runs before any config-tree entry mounts,
|
||||
// so its failure is host setup, not the plugin tree.
|
||||
let stage = 'host preparation failed'
|
||||
try {
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
await prepare?.(ctx)
|
||||
stage = 'plugin tree failed to load'
|
||||
await mountRootInclude(ctx, absoluteConfigPath, patches)
|
||||
// A surface can finish and dispose the whole tree while startup is still
|
||||
// in flight: the TUI renders as soon as its own fiber starts, so an `/exit`
|
||||
// typed before the last entry settles tears the context down under us. The
|
||||
// Loader service goes with it, and the activation audit describes a live
|
||||
// tree — reading `ctx.loader` past this point would throw a TypeError over
|
||||
// an app that exited exactly as asked. Transactional group updates settle
|
||||
// lifecycle inside the mount, so the teardown can land before it returns;
|
||||
// re-check after every await.
|
||||
await ctx.get('loader')?.await()
|
||||
if (ctx.get('loader') === undefined) return ctx
|
||||
await assertEntriesActivated(ctx, binName)
|
||||
return ctx
|
||||
} catch (cause) {
|
||||
// Root-fiber disposal contains cleanup failures per observer (Cordis
|
||||
// fiber.ts hardening) and a repeated call returns the settled single-shot
|
||||
// result, so this await cannot reject and replace `cause`.
|
||||
await ctx.fiber.dispose()
|
||||
const detail = cause instanceof Error ? cause.message : String(cause)
|
||||
// The transactional Loader wraps a failing entry apply in one message per
|
||||
// tree layer; every layer's message is folded into `detail` above, and the
|
||||
// deepest cause is the plugin's own thrown error, whose stack names the
|
||||
// real failure site — append it so the startup diagnostic preserves the
|
||||
// original activation error instead of only the wrap chain.
|
||||
let deepest: unknown = cause
|
||||
while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
|
||||
const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
|
||||
throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-section name for the harness-source location line an app bin adds after boot. */
|
||||
|
||||
@@ -325,6 +325,22 @@ describe('boot', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('disposes partial host setup and labels non-Error preparation failures', async () => {
|
||||
const dir = tmp()
|
||||
const failure = 42
|
||||
let disposed = false
|
||||
const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
|
||||
ctx.effect(() => () => { disposed = true })
|
||||
throw failure
|
||||
})
|
||||
|
||||
await expect(task).rejects.toMatchObject({
|
||||
message: `${NAME}: host preparation failed: ${failure}`,
|
||||
cause: failure,
|
||||
})
|
||||
expect(disposed).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes dshHomePath to Loader config expressions', async () => {
|
||||
const dir = tmp()
|
||||
const dshHome = join(dir, 'home')
|
||||
@@ -375,7 +391,37 @@ describe('boot', () => {
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
|
||||
`${NAME}: plugin tree failed to load: failed to apply loader entry`,
|
||||
)
|
||||
})
|
||||
|
||||
it('appends the deepest cause with its original stack to the load failure', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'failing.mjs'), [
|
||||
'export function apply() {',
|
||||
" const failure = new Error('pinned activation failure')",
|
||||
" failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
|
||||
' throw failure',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
|
||||
String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
|
||||
String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
|
||||
].join('')))
|
||||
})
|
||||
|
||||
it('falls back to the deepest cause message when its stack was erased', async () => {
|
||||
const dir = tmp()
|
||||
const deepest = new Error('stackless deep failure')
|
||||
delete (deepest as { stack?: string }).stack
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
|
||||
throw new Error('wrapped setup failure', { cause: deepest })
|
||||
})).rejects.toThrow(
|
||||
`${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
|
||||
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
|
||||
* rethrows a config-file parse error would kill a live app on one bad
|
||||
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
|
||||
* callback nobody else catches). These tests pin the vendored
|
||||
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
|
||||
* keeps the last good tree, and a valid re-read re-applies overlay patches
|
||||
* exactly like the initial load.
|
||||
* Transactional config replacement through the booted Include and Loader tree.
|
||||
* HMR contains rejected refreshes; direct callers receive the error after the
|
||||
* previous generation has been retained or restored.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
@@ -15,6 +10,7 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Include } from '@cordisjs/plugin-include'
|
||||
import { Group } from '@cordisjs/plugin-loader'
|
||||
import { boot } from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -27,9 +23,10 @@ interface TreeFixture {
|
||||
include: Include
|
||||
}
|
||||
|
||||
async function bootTree(configBody: string): Promise<TreeFixture> {
|
||||
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
|
||||
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
||||
@@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
function entryById(ctx: Context, id: string) {
|
||||
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
|
||||
if (!entry) throw new Error(`missing loader entry ${id}`)
|
||||
return entry
|
||||
}
|
||||
|
||||
function plugin(name: string, body = ''): string {
|
||||
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
|
||||
}
|
||||
|
||||
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
|
||||
try {
|
||||
await task
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
|
||||
return
|
||||
}
|
||||
throw new Error(`expected loader update to fail during ${stage}`)
|
||||
}
|
||||
|
||||
describe('include refresh with an invalid file', () => {
|
||||
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
||||
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
// An empty file parses to `undefined` without a YAML error; it must be
|
||||
// treated exactly like a parse failure, not crash the entry walk.
|
||||
writeFileSync(join(dir, 'cordis.yml'), '')
|
||||
await expect(include.refresh()).resolves.toBeUndefined()
|
||||
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
||||
@@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader entry replacement', () => {
|
||||
it('imports a changed name before replacing the running plugin', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'new.mjs': plugin('newPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
await entry.update({ name: './new.mjs' })
|
||||
expect(entry.options.name).toBe('./new.mjs')
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains the running plugin when the replacement cannot be imported', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous plugin after replacement application fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
||||
'old.mjs': plugin('oldPlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const previous = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
|
||||
expect(entry.options.name).toBe('./old.mjs')
|
||||
expect(entry.fiber === previous).toBe(false)
|
||||
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
|
||||
expect(entry.options.disabled).toBeUndefined()
|
||||
await entry.fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous config when an in-place restart fails', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.fiber === fiber).toBe(true)
|
||||
await fiber?.await()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not persist a failed direct fiber update', async () => {
|
||||
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
||||
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
try {
|
||||
const entry = entryById(ctx, 'target')
|
||||
const fiber = entry.fiber
|
||||
if (!fiber) throw new Error('target entry has no fiber')
|
||||
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
|
||||
expect(entry.options.config).toEqual({ fail: false })
|
||||
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('loader tree replacement', () => {
|
||||
it('rolls back earlier updates and additions when a later entry fails', async () => {
|
||||
const { ctx, dir, include } = await bootTree([
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: old',
|
||||
'',
|
||||
].join('\n'), {
|
||||
'configurable.mjs': plugin('configurablePlugin'),
|
||||
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
||||
})
|
||||
try {
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: candidate',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'- id: bad',
|
||||
' name: ./bad.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), [
|
||||
'- id: existing',
|
||||
' name: ./configurable.mjs',
|
||||
' config:',
|
||||
' value: committed',
|
||||
'- id: added',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
await include.refresh()
|
||||
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
|
||||
expect(entryById(ctx, 'added').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
|
||||
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const config = (disabled: boolean) => [
|
||||
'- id: parent',
|
||||
' name: cordis:group',
|
||||
' group: true',
|
||||
` disabled: ${disabled}`,
|
||||
' config:',
|
||||
' - id: child',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(true))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeUndefined()
|
||||
|
||||
writeFileSync(join(dir, 'cordis.yml'), config(false))
|
||||
await include.refresh()
|
||||
expect(entryById(ctx, 'child').fiber).toBeDefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a programmatic entry move when its update fails', async () => {
|
||||
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
|
||||
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
||||
})
|
||||
ctx.loader.builtins.group = Group
|
||||
try {
|
||||
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
|
||||
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
|
||||
const target = entryById(ctx, targetId)
|
||||
const source = target.parent
|
||||
const sourceIndex = source.data.indexOf(target.options)
|
||||
const destination = entryById(ctx, groupId).subgroup
|
||||
if (!destination) throw new Error('created loader group has no subgroup')
|
||||
|
||||
await expectUpdateFailure(
|
||||
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
|
||||
'apply',
|
||||
)
|
||||
|
||||
expect(target.parent).toBe(source)
|
||||
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
|
||||
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
|
||||
expect(destination.data).not.toContain(target.options)
|
||||
expect(target.options.config).toEqual({ fail: false })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('include refresh with overlay patches', () => {
|
||||
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|
||||
@@ -116,9 +328,9 @@ describe('include refresh with overlay patches', () => {
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
|
||||
|
||||
// Removing every patch must revert to the file's own values: patching
|
||||
// may not bake earlier patch results into the cached parse.
|
||||
await entry.update({ config: { path: './base.yml', patches: [] } })
|
||||
// Omitting the patch list must remove the overlay rather than reuse the
|
||||
// Include's previous config through a default parameter.
|
||||
await entry.update({ config: { path: './base.yml' } })
|
||||
await ctx.loader.await()
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
|
||||
} finally {
|
||||
|
||||
142
packages/ui/app-boot/tests/hmr-config.spec.ts
Normal file
142
packages/ui/app-boot/tests/hmr-config.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
async function bootHmr(dir: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dir).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
describe('HMR exact config paths', () => {
|
||||
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
try {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
observed.push('missing')
|
||||
}
|
||||
})
|
||||
|
||||
writeFileSync(filename, 'one', { flag: 'wx' })
|
||||
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
|
||||
writeFileSync(filename, 'two')
|
||||
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
|
||||
unlinkSync(filename)
|
||||
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const dir = join(root, 'later')
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(root)
|
||||
const observed: string[] = []
|
||||
try {
|
||||
await ctx.hmr.registerConfig(filename, () => {
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
})
|
||||
mkdirSync(dir)
|
||||
writeFileSync(filename, 'created')
|
||||
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
writeFileSync(filename, 'one')
|
||||
const ctx = await bootHmr(dir)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const observed: string[] = []
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
try {
|
||||
const dispose = await ctx.hmr.registerConfig(filename, async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
observed.push(readFileSync(filename, 'utf8'))
|
||||
if (observed.length === 1) {
|
||||
started.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
active -= 1
|
||||
})
|
||||
await started.promise
|
||||
writeFileSync(filename, 'two')
|
||||
// Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
|
||||
// that window so this edit is queued before registration disposal.
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
|
||||
let disposed = false
|
||||
const disposal = dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
release.resolve(undefined)
|
||||
await disposal
|
||||
expect(maxActive).toBe(1)
|
||||
expect(observed).toEqual(['one', 'two'])
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
|
||||
const filename = join(dir, 'plugins.yml')
|
||||
const ctx = await bootHmr(dir)
|
||||
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
|
||||
let failureCount = 0
|
||||
try {
|
||||
ctx.on('hmr/config-update-failed', () => {
|
||||
throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failureCount += 1
|
||||
failure.resolve({ filename: failedFilename, error })
|
||||
})
|
||||
await ctx.hmr.registerConfig(filename, () => { throw 42 })
|
||||
writeFileSync(filename, 'invalid')
|
||||
|
||||
const observed = await failure.promise
|
||||
expect(observed.filename).toBe(filename)
|
||||
expect(observed.error).toBeInstanceOf(Error)
|
||||
expect(observed.error.message).toBe('42')
|
||||
|
||||
writeFileSync(filename, 'invalid again')
|
||||
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,21 +4,36 @@
|
||||
* a real Loader tree.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import {
|
||||
boot,
|
||||
loadPersonalPatches,
|
||||
PERSONAL_CONFIG_FILENAME,
|
||||
watchPersonalPatches,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
|
||||
|
||||
async function eventually(test: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 10_000
|
||||
while (!test()) {
|
||||
if (Date.now() >= deadline) throw new Error(message)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
|
||||
|
||||
describe('loadPersonalPatches', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DSH_HOME
|
||||
@@ -86,7 +101,13 @@ describe('loadPersonalPatches', () => {
|
||||
|
||||
describe('boot with personal patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'noop.mjs'), [
|
||||
'export const name = "noop"',
|
||||
'export function apply(_ctx, config = {}) {',
|
||||
' if (config.fail) throw new Error("candidate config failed")',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
return join(dir, 'cordis.yml')
|
||||
}
|
||||
@@ -138,4 +159,112 @@ describe('boot with personal patches', () => {
|
||||
await ctxEmpty.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
const filename = join(personal, PERSONAL_CONFIG_FILENAME)
|
||||
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
|
||||
const ctx = await boot(NAME, writeTree(dir), basePatches)
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const failures: Array<{ filename: string; error: Error }> = []
|
||||
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
|
||||
failures.push({ filename: failedFilename, error })
|
||||
})
|
||||
const dispose = await watchPersonalPatches(ctx, {
|
||||
binName: NAME,
|
||||
dir: personal,
|
||||
compose: personalPatches => [...basePatches, ...personalPatches],
|
||||
})
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied')
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
|
||||
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
|
||||
expect(failures[0]).toMatchObject({ filename })
|
||||
expect(failures[0]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
writeFileSync(filename, 'invalid: [unclosed\n')
|
||||
await eventually(() => failures.length === 2, 'parse failure was not broadcast')
|
||||
expect(failures[1]?.error).toBeInstanceOf(Error)
|
||||
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: recovered\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
unlinkSync(filename)
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(2)
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
// Default compose: the personal overlay IS the whole patch list, so a
|
||||
// fresh generation replaces the app-owned layer instead of stacking on it.
|
||||
await dispose()
|
||||
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied')
|
||||
} finally {
|
||||
await disposeDefault()
|
||||
}
|
||||
} finally {
|
||||
await dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
|
||||
const dir = tmp()
|
||||
const withoutHmr = await boot(NAME, writeTree(dir))
|
||||
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service')
|
||||
await withoutHmr.fiber.dispose()
|
||||
|
||||
const withoutInclude = new Context()
|
||||
withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href
|
||||
await withoutInclude.plugin(Loader)
|
||||
await withoutInclude.plugin(Timer)
|
||||
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry')
|
||||
await withoutInclude.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
|
||||
// A TUI `/exit` typed during startup disposes the whole tree while
|
||||
// registerConfig's effect registration is still in flight (the HMR effect
|
||||
// then fails with INACTIVE_EFFECT); the app is exiting exactly as asked,
|
||||
// so the watcher must not crash the process. The stub makes the race
|
||||
// deterministic — the live-teardown ordering itself is not stageable.
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
|
||||
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
|
||||
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates registration failures other than mid-teardown', async () => {
|
||||
const dir = tmp()
|
||||
const personal = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
|
||||
// Same personal path registered twice: HMR refuses; not a teardown race.
|
||||
await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered')
|
||||
await dispose()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
159
packages/ui/app-boot/tests/repository-cache.spec.ts
Normal file
159
packages/ui/app-boot/tests/repository-cache.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const roots: string[] = []
|
||||
|
||||
async function temporaryRoot(name: string): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
async function fakePackage(directory: string): Promise<void> {
|
||||
const target = join(directory, 'node_modules', 'repository')
|
||||
await mkdir(target, { recursive: true })
|
||||
await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('RepositoryCache', () => {
|
||||
it('single-flights and permanently reuses an exact specifier', async () => {
|
||||
const root = await temporaryRoot('repository-cache')
|
||||
const calls: string[] = []
|
||||
const install: RepositoryInstall = async (directory) => {
|
||||
calls.push(directory)
|
||||
await fakePackage(directory)
|
||||
}
|
||||
const cache = new RepositoryCache(root, install)
|
||||
const specifier = 'github:owner/repository#0123456789abcdef'
|
||||
|
||||
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
|
||||
expect(concurrent).toBe(first)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
|
||||
expect(await reopened.resolve(specifier)).toBe(first)
|
||||
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
|
||||
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
|
||||
dependencies: { repository: specifier },
|
||||
})
|
||||
|
||||
const second = await cache.resolve('github:owner/repository#fedcba9876543210')
|
||||
expect(second).not.toBe(first)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('accepts the valid winner when independent cache instances race', async () => {
|
||||
const root = await temporaryRoot('repository-race')
|
||||
const bothStarted = Promise.withResolvers<undefined>()
|
||||
let starts = 0
|
||||
const install: RepositoryInstall = async (directory) => {
|
||||
await fakePackage(directory)
|
||||
starts += 1
|
||||
if (starts === 2) bothStarted.resolve(undefined)
|
||||
await bothStarted.promise
|
||||
}
|
||||
const specifier = 'github:owner/repository#race'
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
new RepositoryCache(root, install).resolve(specifier),
|
||||
])
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(starts).toBe(2)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes a failed staging tree and permits an exact retry', async () => {
|
||||
const root = await temporaryRoot('repository-retry')
|
||||
let attempts = 0
|
||||
const cache = new RepositoryCache(root, async (directory) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw new Error('install failed')
|
||||
await fakePackage(directory)
|
||||
})
|
||||
|
||||
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
|
||||
expect(await readdir(root)).toEqual([])
|
||||
await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects empty or padded specifiers before touching the cache', async () => {
|
||||
const root = await temporaryRoot('repository-input')
|
||||
const cache = new RepositoryCache(root, fakePackage)
|
||||
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
|
||||
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
|
||||
await expect(readdir(root)).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
|
||||
const root = await temporaryRoot('repository-corrupt')
|
||||
const specifier = 'github:owner/repository#corrupt'
|
||||
const key = createHash('sha256').update(specifier).digest('hex')
|
||||
const entry = join(root, key)
|
||||
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
|
||||
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
|
||||
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
|
||||
|
||||
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
|
||||
})
|
||||
|
||||
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
|
||||
const root = await temporaryRoot('repository-pnpm')
|
||||
const repository = join(root, 'source')
|
||||
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
|
||||
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
|
||||
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-fixture',
|
||||
version: '1.0.0',
|
||||
})}\n`)
|
||||
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
|
||||
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
|
||||
name: 'repository-plugin-fixture',
|
||||
version: '1.0.0',
|
||||
scripts: { prepare: 'node prepare.mjs' },
|
||||
dsh: { skills: ['../skills'] },
|
||||
})}\n`)
|
||||
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
|
||||
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
|
||||
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
|
||||
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
|
||||
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
|
||||
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
|
||||
'',
|
||||
].join('\n'))
|
||||
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
|
||||
await execFileAsync('git', ['add', '.'], { cwd: repository })
|
||||
await execFileAsync('git', [
|
||||
'-c', 'user.name=Repository Fixture',
|
||||
'-c', 'user.email=repository@example.invalid',
|
||||
'commit', '--quiet', '-m', 'fixture',
|
||||
], { cwd: repository })
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
|
||||
const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
|
||||
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
|
||||
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
|
||||
|
||||
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
|
||||
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
|
||||
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
|
||||
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
|
||||
.resolves.toBe('repository skill source\n')
|
||||
await expect(readFile(join(installed, 'package.json'), 'utf8'))
|
||||
.resolves.toContain('repository-plugin-fixture')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/hmr"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user