fix(repository-plugin): require published prepare dependency

This commit is contained in:
Tianyi Cui
2026-08-09 00:16:14 +08:00
parent e91ff6d499
commit 29b3e3fa84
26 changed files with 319 additions and 243 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md
2026-07-30-static-repository-plugin-format.md: 615529c89d32ed87c73d100b632318500e1ae86c
2026-07-30-static-repository-plugin-format.zh.md: bb90994defccdaf2044b467cb3c5018c1c94641a
2026-07-30-static-repository-plugin-format.md: afc88bb974ccc6ef7843eaf1e98f16b65bb6d4b3
2026-07-30-static-repository-plugin-format.zh.md: a6dc6ec6ec89c04b79edea279029ae33091ca0af

View File

@@ -14,7 +14,7 @@ The [package-manager-native repository cache](2026-07-30-package-manager-native-
`@deepseek-ai/dsh-repository-plugin` owns the static contribution subformat inside a `.dsh-plugin` package: skill roots and one common `.mcp.json`. Its package metadata uses `package.json#dsh.skills` for relative skill-root paths and `package.json#dsh.mcpServers` for the relative MCP document path. Each path may leave `.dsh-plugin` to reuse repository content but must remain beneath the directory containing that `.dsh-plugin`; a nested selectable Plugin therefore owns the adjacent subtree above its package without gaining access to unrelated host paths. The package may additionally declare the explicit code entry owned by the [trusted repository package decision](2026-08-08-trusted-repository-package-code.md), and at least one code or static contribution is required.
The `.dsh-plugin` package declares a non-empty `scripts.prepack` that invokes `dsh-plugin-prepare` without depending on a DSH npm package. During Git installation, the standalone runtime temporarily supplies that command from its own build on the isolated lifecycle `PATH`; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The host-owned command rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md).
The `.dsh-plugin` package declares the published `@deepseek-ai/dsh-repository-plugin` package as a development dependency and a non-empty `scripts.prepack` that invokes its `dsh-plugin-prepare` executable. During Git installation, pnpm installs that dependency from the selected package's own manifest; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The dependency and workspace-isolation rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md).
Loading the DSH package registers that builtin as an effect. A generated wrapper mounts the builtin as its child with `import.meta.url`, so all contributions belong to the wrapper fiber and disappear on Loader removal or rollback. The builtin revalidates the prepared manifest and path containment before reading assets. It composes the existing implementations rather than registering skills or MCP tools itself.

View File

@@ -14,7 +14,7 @@
`@deepseek-ai/dsh-repository-plugin` 负责 `.dsh-plugin` 包内的静态贡献子格式skill 根和一个通用 `.mcp.json`。其包元数据使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径。每条路径都可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的插件可以拥有其包上方相邻的子树,却不能访问无关宿主路径。该包还可以声明由[受信任 repository 包决策](2026-08-08-trusted-repository-package-code.md)负责的显式代码入口,并且至少需要一种代码或静态贡献。
`.dsh-plugin` 包声明非空 `scripts.prepack`调用 `dsh-plugin-prepare` 且不依赖 DSH NPM 包。在 Git 安装期间,独立运行时会从自身构建产物中临时提供该命令,并将其放入隔离的生命周期 `PATH``prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。包可以先构建其代码。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的生命周期元数据是否包含辅助命令。仅含静态贡献的包仍会获得无 import 包装层,其中包含规范化 manifest(元数据清单)、由服务派生的 `inject` 列表,以及对 `dsh-repository-plugin` Loader builtin 的委托。宿主自有命令的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。
`.dsh-plugin`将已发布的 `@deepseek-ai/dsh-repository-plugin` 包声明为开发依赖,并声明非空 `scripts.prepack`调用 `dsh-plugin-prepare` 可执行文件。在 Git 安装期间pnpm 会按所选包自身的 manifest元数据清单安装该依赖`prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。包可以先构建其代码。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的生命周期元数据是否包含辅助命令。仅含静态贡献的包仍会获得无 import 包装层,其中包含规范化 manifest、由服务派生的 `inject` 列表,以及对 `dsh-repository-plugin` Loader builtin 的委托。依赖与 workspace 隔离的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。
加载 DSH package 会以 effect 方式注册该 builtin。生成的包装模块使用 `import.meta.url` 把 builtin 挂载为自己的子级,因此所有贡献都归属于包装 fiber并在 Loader 移除或回滚时消失。Builtin 会在读取资源前重新校验已准备 manifest 与路径包含关系。它只组合现有实现,而不自行注册 skills 或 MCP 工具。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md
2026-08-08-trusted-repository-package-code.md: cf72853836901af9c1c9e9f0d3d23f43997a4e97
2026-08-08-trusted-repository-package-code.zh.md: cea47d85973a81f904b1596051ac58996459c635
2026-08-08-trusted-repository-package-code.md: 6c568964c2d4392054107acb1e73ace8f50d5f0b
2026-08-08-trusted-repository-package-code.zh.md: c2587286379398ab853b71c8b8819d274d06c65b

View File

@@ -14,7 +14,7 @@ A repository author also needs to keep an ordinary TypeScript npm package shape.
A configured repository package is trusted code. Its `.dsh-plugin/package.json` may declare `dsh.entry` as a relative path to a compiled ESM Cordis Plugin inside that package, alongside or instead of `dsh.skills` and `dsh.mcpServers`. At least one contribution is required. The entry may use namespace exports or a default export and retains ordinary Cordis semantics for `name`, `inject`, `Config`, registrations, startup failure, and effect-scoped teardown.
The package owns its npm dependencies and build toolchain. `scripts.prepack` is a non-empty package-authored command that must invoke the host-supplied `dsh-plugin-prepare`, but it may first run `tsc`, `tsdown`, or any other build. DSH neither parses the shell program nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; missing wrapper or build outputs fail before a cache generation becomes usable.
The package owns its npm dependencies and build toolchain. It declares the published `@deepseek-ai/dsh-repository-plugin` package to obtain the `dsh-plugin-prepare` executable. `scripts.prepack` is a non-empty package-authored command that must invoke that dependency-provided helper, but it may first run `tsc`, `tsdown`, or any other build. DSH neither injects the helper, parses the shell program, nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; a missing dependency, wrapper, or build output fails before a cache generation becomes usable.
The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together.
@@ -48,4 +48,4 @@ Model-visible behavior remains governed by the owning DSH seam. A repository ent
Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained connect failure, and prove strict startup rejection still closes the client.
The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. That repository package installs pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Cache assertions require source files to be absent from the packed installation while both built modules, their installed dependency, copied assets, and generated wrapper are present.
The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. The test packs the current repository Plugin build with the same private-field removal and workspace-dependency pinning used for publication, serves its packument and tarball from a job-local npm registry, and directs the Git package's ordinary scoped npm resolution there. That repository package obtains `dsh-plugin-prepare` from the simulated published dependency, installs its other pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Registry and cache assertions require npm resolution to reach the simulated publication, source files to be absent from the packed installation, and both built modules, their installed dependency, copied assets, and generated wrapper to be present.

View File

@@ -14,7 +14,7 @@
已配置的 repository 包是受信任代码。其 `.dsh-plugin/package.json` 可以连同 `dsh.skills``dsh.mcpServers` 声明 `dsh.entry`,也可以用它取代二者;`dsh.entry` 是指向该包内已编译 ESM Cordis 插件的相对路径。至少需要一种贡献。入口可以使用 namespace 导出或 default export并沿用 Cordis 对 `name``inject``Config`、注册、启动失败和 effect 作用域清理的常规语义。
包自行负责其 NPM 依赖和构建工具链。`scripts.prepack` 是由包作者编写的非空命令,必须调用宿主提供的 `dsh-plugin-prepare`,但可以先运行 `tsc``tsdown` 或其他任意构建。DSH 不解析该 shell 程序,也不编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;包装层或构建输出缺失会在缓存 generation 可用前导致失败。
包自行负责其 NPM 依赖和构建工具链。它声明已发布的 `@deepseek-ai/dsh-repository-plugin` 包以取得 `dsh-plugin-prepare` 可执行文件。`scripts.prepack` 是由包作者编写的非空命令,必须调用该依赖提供的辅助程序,但可以先运行 `tsc``tsdown` 或其他任意构建。DSH 不会注入辅助程序,也不会解析该 shell 程序编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;依赖、包装层或构建输出缺失会在缓存 generation 可用前导致失败。
生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose资源释放会一并撤销入口、skill 提供方、MCP client 及其 effect。
@@ -48,4 +48,4 @@
repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skillMCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留选择收束连接失败的能力,并证明严格启动拒绝仍会关闭 client。
Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PRPull Request的精确 head SHA 且经过认证的私有 GitHub 源。该 repository 包安装固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。缓存断言要求打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。
Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PRPull Request的精确 head SHA 且经过认证的私有 GitHub 源。测试会采用发布时相同的移除 `private` 字段和固定 workspace 依赖版本流程,对当前 repository 插件构建进行打包;再由作业本地 NPM 注册表提供其 `packument` 与 tarball并把 Git 包的常规 scoped NPM 解析指向该注册表。该 repository 包从模拟发布的依赖取得 `dsh-plugin-prepare`,安装其他固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。注册表与缓存断言要求 NPM 解析必须命中模拟发布,打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。

View File

@@ -1,48 +0,0 @@
# Agent Note: Host-owned preparation makes GitHub repository Plugins installable
Status: implemented
English | [中文](2026-08-08-host-owned-git-repository-plugin-preparation.zh.md)
## Problem
The repository Plugin authoring contract depended on `scripts.prepare: "dsh-plugin-prepare"` and told source repositories to add `@deepseek-ai/dsh-repository-plugin` as a development dependency. That package is private and not published to npm, so an otherwise valid external GitHub repository could not obtain the helper in a clean install.
The lifecycle choice also failed for a selectable `.dsh-plugin` inside a pnpm workspace. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` joins the containing workspace and need not execute the unlisted `.dsh-plugin` package's `prepare` script. The install could therefore succeed and publish a cache generation containing only the source package metadata; real DSH startup failed later because `dsh-plugin.mjs` did not exist.
The same workspace discovery could suppress package-owned dependencies after the move to `prepack`. When the source repository carried a root pnpm lockfile but did not list the selected `.dsh-plugin` as a workspace importer, pnpm reported a successful workspace install without installing dependencies declared only by that package. Its TypeScript build then failed because Cordis and the MCP SDK were absent.
The checked-in headless fixture did not catch either defect because it mounted an already prepared wrapper. It proved runtime composition, not GitHub acquisition or package preparation.
## Decision
The authoring format requires a non-empty `scripts.prepack` that invokes `dsh-plugin-prepare` and needs no DSH dependency for that helper. The package may declare its own build and runtime dependencies and run compilation before the helper. pnpm's Git-hosted package preparation invokes `prepack` explicitly after its dependency-install step and before packlist selects the `.dsh-plugin` subtree, so the helper can validate built entries and still copy sibling repository assets such as `../skills` into the package.
`@deepseek-ai/dsh-repository-plugin` materializes short-lived POSIX and Windows command wrappers that invoke its own built `dsh-plugin-prepare` entry. `RepositoryCache` accepts caller-owned executable directories, resolves them absolutely, and prepends them to the credential-scrubbed lifecycle `PATH` passed to bundled pnpm. It also prepends a transaction-owned `pnpm` wrapper: the outer install still runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes that same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile. Both command directories are removed after the child settles. The repository remains trusted package-manager input: DSH supplies the two host commands, but all package lifecycle scripts and dependencies still execute under the existing trust contract.
The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. Because that repository is private, the workflow writes a job-scoped Git configuration that uses the read-only job token for GitHub HTTPS and rewrites pnpm's SSH fallback to that authenticated transport. Its built-entry acceptance launches the real `apps/cli/lib/bin.js run` command with a one-run patch selecting a `private: true` GitHub fixture. That fixture installs pinned npm dependencies, type-checks and bundles a TypeScript Cordis entry and MCP server in `prepack`, invokes the host helper, and proves the skill, MCP call, and code entry through real model requests and immutable-cache artifacts. The test fails if CI omits the exact source instead of silently skipping.
## Alternatives considered
**Publish the prepare helper to npm.** Rejected because the source package would acquire a release/version dependency solely to call code already owned by the running DSH installation, and the existing helper is intentionally private.
**Keep `prepare` and only inject the command.** Rejected because command availability does not make a nested package's `prepare` lifecycle run when the Git repository's package manager treats it as part of another workspace.
**Prepare after RepositoryCache installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist.
**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager.
**Add the in-repository CI fixture to this repository's pnpm workspace.** Rejected because that would repair only the proof fixture and leave an arbitrary selected package vulnerable to its containing repository's workspace membership and lockfile.
## Consequences
- A repository author can commit the fixed `.dsh-plugin/package.json` and source assets to GitHub without publishing either the Plugin or its preparation helper to npm.
- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials.
- `prepack`, not `prepare`, is part of the pre-release authoring format. It may contain package-owned build steps but must invoke the host helper; missing or empty lifecycle metadata fails installed-package validation instead of producing an ambiguous partial format.
- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It must declare its dependencies and cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs.
- Exact source strings still identify immutable cache generations; a changed ref or source configuration selects another generation.
- The host supplies only the preparation executable. Package dependencies, compilation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md).
## Testing
`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires a relative `file:` build dependency during `prepack`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and temporary command cleanup. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection.

View File

@@ -1,48 +0,0 @@
# Agent Note: 宿主自有的准备机制使 GitHub repository 插件可安装
状态:已实现
[English](2026-08-08-host-owned-git-repository-plugin-preparation.md) | 中文
## 问题
repository 插件的创作契约依赖 `scripts.prepare: "dsh-plugin-prepare"`,并要求源码仓库将 `@deepseek-ai/dsh-repository-plugin` 添加为开发依赖。该包是私有包,且未发布到 NPM因此即使外部 GitHub 仓库符合其他要求,也无法在全新安装中取得该辅助程序。
这种生命周期选择也无法支持 pnpm 工作区内可选的 `.dsh-plugin`。pnpm 会先运行 Git 托管仓库首选的包管理器,再打包选定的子目录,从而准备 Git 托管包。嵌套执行的 `pnpm install` 会加入外层工作区,而不一定执行未列入其中的 `.dsh-plugin` 包的 `prepare` 脚本。因此,安装可能成功并发布一个仅包含源包元数据的缓存 generation随后真实 DSH 启动因 `dsh-plugin.mjs` 不存在而失败。
迁移到 `prepack` 后,同一项 workspace 发现行为还可能抑制包自有依赖。如果源仓库带有根 pnpm lockfile却未把所选 `.dsh-plugin` 列为 workspace importerpnpm 会报告 workspace 安装成功,但不会安装仅由该包声明的依赖。随后其 TypeScript 构建会因缺少 Cordis 和 MCP SDK 而失败。
签入仓库的 headless fixture测试前置数据没有捕获任一缺陷因为它挂载的是已准备好的包装层。它证明的是运行时组合而不是 GitHub 获取或包准备。
## 决策
创作格式要求 `scripts.prepack` 非空且调用 `dsh-plugin-prepare`,使用该辅助程序无需 DSH 依赖。包可以声明自己的构建依赖与运行时依赖并在调用辅助程序前完成编译。pnpm 针对 Git 托管包的准备流程会在依赖安装步骤之后、打包清单选择 `.dsh-plugin` 子树之前显式调用 `prepack`,因此辅助程序可以校验构建入口,并继续把 `../skills` 等同仓库的相邻资源复制进包内。
`@deepseek-ai/dsh-repository-plugin` 会生成临时的 POSIX 和 Windows 命令包装脚本,用于调用其自有的已构建 `dsh-plugin-prepare` 入口。`RepositoryCache` 接受由调用方持有的可执行文件目录,将它们解析为绝对路径,再前置到传给随附 pnpm、已清除凭据的包生命周期 `PATH`。它还会前置一个由安装事务持有的 `pnpm` 包装命令:外层安装仍直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会通过 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下所选包仍自行拥有依赖解析。两个命令目录都会在子进程结算后移除。仓库仍是受信任的包管理器输入DSH 提供这两条宿主命令,但所有包生命周期脚本和依赖仍按既有信任契约执行。
Node 24 消费方 CI 任务会传入从 PRPull Requesthead 仓库和 SHA 派生的精确源。由于该仓库为私有仓库,工作流会写入一份作业作用域的 Git 配置,使用该作业的只读 token 对 GitHub HTTPS 连接进行认证,并将 pnpm 的 SSH 回退路径重写为这一已认证的传输方式。其构建入口验收会启动真实的 `apps/cli/lib/bin.js run` 命令,并通过一个仅作用于当次运行的 patch 选择 `private: true` 的 GitHub fixture。该 fixture 安装固定版本的 NPM 依赖,在 `prepack` 中对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,调用宿主辅助程序,并通过真实模型请求和不可变缓存产物验证 skill技能、MCP 调用和代码入口。如果 CI 遗漏精确源,测试会失败,而不是静默跳过。
## 考虑过的替代方案
**把准备辅助程序发布到 NPM。** 拒绝,因为源包会仅为了调用当前运行的 DSH 安装本就拥有的代码,而增加一个需要发布和管理版本的依赖;现有辅助程序又有意保持私有。
**保留 `prepare`,只注入命令。** 拒绝,因为当 Git 仓库的包管理器把嵌套包当作另一工作区的一部分时,即使命令可用,也不会使嵌套包的 `prepare` 生命周期得以运行。
**在 RepositoryCache 安装选定包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成打包清单前完成。
**在 DSH 中克隆 GitHub 仓库,并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定版本的包管理器负责的 ref 解析、子目录选择、依赖安装、打包清单行为和缓存完整性。
**把仓库内的 CI fixture 加入本仓库的 pnpm workspace。** 拒绝,因为这只能修复证明用的 fixture任意所选包仍会受其所在仓库的 workspace membership 与 lockfile 影响。
## 后果
- 仓库作者可以把修复后的 `.dsh-plugin/package.json` 和源资源提交到 GitHub而无需把插件或其准备辅助程序发布到 NPM。
- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。
- 预发布创作格式使用 `prepack` 而不是 `prepare`。其中可以包含包自有构建步骤,但必须调用宿主辅助程序;生命周期元数据缺失或为空会在已安装包校验时失败,而不会留下状态不明的半成品格式。
- pnpm 仓库中的所选包按自身 manifest 安装,而不是按外层 workspace 安装。它必须声明自己的依赖,不能依赖仅由 workspace 提升而可见的包;常规 registry 依赖与相对 `file:` 依赖仍是包自有输入。
- 精确源字符串仍标识不可变缓存 generation改变 ref 或源配置会选择另一个 generation。
- 宿主只提供准备阶段可执行文件。包依赖、编译和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。
## 测试
`packages/ui/app-boot/tests/repository-cache.spec.ts` 会让一个被源仓库根 pnpm lockfile 排除的包通过本地 Git 子路径运行,并要求 `prepack` 使用相对 `file:` 构建依赖;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据和临时命令清理行为。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:全新的 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建产物的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM大语言模型请求观测以及对已准备缓存的检查。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md
2026-08-08-host-owned-git-repository-plugin-preparation.md: 3f92c0b2d782735bfb548711bb13a7a8d03a3824
2026-08-08-host-owned-git-repository-plugin-preparation.zh.md: ef34c99b5d9f31ea13c5fddfee77c30e640d25c9
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md
2026-08-08-npm-backed-git-repository-plugin-preparation.md: 9437043d007f5331f36f1c4cff1fe0a3e768b060
2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md: 1f06f7372dc0d948906ec4441cb161d2d2ed69e5

View File

@@ -0,0 +1,48 @@
# Agent Note: npm-backed preparation makes GitHub repository Plugins self-contained
Status: implemented
English | [中文](2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md)
## Problem
The repository Plugin authoring contract requires `scripts.prepack` to invoke `dsh-plugin-prepare`. Supplying that executable from the running DSH installation made a source package appear valid even when its own manifest could not obtain the helper. It therefore did not prove the behavior users need after `@deepseek-ai/dsh-repository-plugin` is published: an ordinary Git-hosted npm package must be installable and preparable from only its declared dependencies.
A selectable `.dsh-plugin` inside a pnpm workspace has a second isolation requirement. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` can join the containing workspace; when the root lockfile does not list `.dsh-plugin` as an importer, pnpm can report success without installing dependencies declared only by that package. Its TypeScript build or prepare command then fails, or a pre-generated artifact hides the missing dependency.
The checked-in headless fixture mounts an already prepared wrapper. It proves runtime composition, not GitHub acquisition, npm resolution, or package-owned preparation.
## Decision
The `.dsh-plugin` package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency and invokes its published `dsh-plugin-prepare` executable from `scripts.prepack`. The package may declare any other build and runtime dependencies and run arbitrary compilation before the helper. The repository Plugin package marks its Cordis and DSH peers optional so a helper-only development install resolves only the helper's actual `zod` runtime dependency; an application composition still supplies the peers used by the package's Cordis entry.
DSH does not materialize or prepend a prepare executable. `RepositoryCache` supplies only a transaction-owned `pnpm` wrapper: the outer install runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes the same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile, and normal package-manager lifecycle `PATH` construction exposes `node_modules/.bin/dsh-plugin-prepare`. The temporary pnpm wrapper disappears after the child settles. The repository remains trusted package-manager input: all dependency and lifecycle code executes under the existing trust contract.
The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. It uses the existing private DeepSeek Harness repository rather than creating another repository per run. A job-scoped Git configuration gives the read-only job token access to that exact private source and rewrites pnpm's SSH fallback to authenticated HTTPS.
The built-entry acceptance also creates an in-process npm registry. It stages the current built `@deepseek-ai/dsh-repository-plugin` as a publication artifact by removing `private`, replacing workspace protocols with the release version, and packing the declared files. The registry serves the resulting packument and tarball, while a job-local npm config directs only the `@deepseek-ai` scope to it. The real built `dsh run` child then fetches the exact Git source; that package resolves the helper through npm, type-checks and bundles a TypeScript Cordis entry and MCP server, prepares the adjacent skill, and loads all three contributions. A deliberately failing host `PATH` command proves the lifecycle selected the dependency-local executable. The acceptance also requires registry resolution and inspects the immutable prepared cache, so restoring a host-injected helper cannot satisfy it.
## Alternatives considered
**Inject `dsh-plugin-prepare` from the running DSH installation.** Rejected because it lets an incomplete repository manifest pass and tests a host-only path that npm consumers cannot reproduce.
**Publish the source fixture itself to npm.** Rejected because the product contract is specifically that the DSH Plugin remains Git-hosted; only the reusable preparation helper is an npm dependency.
**Create a new private GitHub repository in every CI run.** Rejected because the pull request repository at its exact head SHA is already a real authenticated private Git remote. Per-run repository mutation would add credentials, cleanup, and eventual-consistency failure modes without changing the acquisition path.
**Prepare after `RepositoryCache` installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist.
**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager.
## Consequences
- A repository author can commit a `.dsh-plugin` package, TypeScript source, skills, and MCP definitions to GitHub without publishing that Plugin package to npm. The package must declare the published preparation dependency.
- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials.
- `prepack`, not `prepare`, is part of the authoring format. It may contain arbitrary package-owned build steps but must invoke the dependency-provided helper; missing dependency or lifecycle metadata fails before a cache generation is usable.
- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs.
- Exact source strings identify immutable cache generations; a changed ref or source configuration selects another generation.
- Package dependencies, compilation, preparation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md).
## Testing
`packages/ui/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires relative `file:` dependencies to provide both its build command and `dsh-plugin-prepare`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and preparation output. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: simulated published helper package, job-local npm registry, fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection.

View File

@@ -0,0 +1,48 @@
# Agent Note: 基于 NPM 的准备机制使 GitHub repository 插件自包含
状态:已实现
[English](2026-08-08-npm-backed-git-repository-plugin-preparation.md) | 中文
## 问题
repository 插件创作契约要求 `scripts.prepack` 调用 `dsh-plugin-prepare`。如果由正在运行的 DSH 安装提供该可执行文件,即使源包自身的 manifest元数据清单无法取得辅助程序它也会显得有效。因此这并未证明 `@deepseek-ai/dsh-repository-plugin` 发布后用户所需的行为:普通 Git 托管 NPM 包必须只依靠自身声明的依赖即可安装和准备。
pnpm workspace 内可选择的 `.dsh-plugin` 还有另一项隔离要求。pnpm 会在打包所选子目录前运行仓库首选的包管理器,以准备 Git 托管包。嵌套的 `pnpm install` 可能加入外层 workspace当根 lockfile 未把 `.dsh-plugin` 列为 importer 时pnpm 可能报告成功,却未安装仅由该包声明的依赖。随后,其 TypeScript 构建或准备命令会失败;也可能因为存在预生成产物,依赖缺失被掩盖。
签入仓库的 headless fixture测试前置数据挂载的是已准备好的包装层。它证明运行时组合而不证明 GitHub 获取、NPM 解析或包自有准备。
## 决策
`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,并在 `scripts.prepack` 中调用其已发布的 `dsh-plugin-prepare` 可执行文件。该包可以声明其他任意构建依赖与运行时依赖并在辅助程序前执行任意编译。repository 插件包把 Cordis 与 DSH 对等依赖peer dependency标为可选因此仅为使用辅助程序而进行的开发安装只会解析辅助程序实际依赖的 `zod` 运行时依赖;应用组合仍会提供该包 Cordis 入口所使用的对等依赖。
DSH 不会生成准备阶段可执行文件,也不会将其前置到 `PATH``RepositoryCache` 只提供一个由事务持有的 `pnpm` 包装脚本:外层安装直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会以 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下,所选包仍自行负责依赖解析,正常的包管理器生命周期 `PATH` 构造会暴露 `node_modules/.bin/dsh-plugin-prepare`。临时 pnpm 包装脚本会在子进程结算后消失。repository 仍是受信任的包管理器输入:所有依赖与生命周期代码都按既有信任契约执行。
Node 24 消费方 CI 任务会传入从 PRPull Requesthead 仓库与 SHA 派生的精确源。它复用现有私有 DeepSeek Harness 仓库,而不会为每次运行新建仓库。作业作用域的 Git 配置允许只读作业 token 访问该精确私有源,并把 pnpm 的 SSH 回退改写为已认证 HTTPS。
构建入口验收还会创建一个进程内 NPM 注册表。它通过移除 `private`、将 workspace protocol 替换为发布版本并打包声明的文件,把当前已构建的 `@deepseek-ai/dsh-repository-plugin` 暂存为发布产物。注册表会提供由此生成的 `packument` 与 tarball作业本地 NPM 配置则只把 `@deepseek-ai` scope 指向它。实际构建的 `dsh run` 子进程随后获取精确 Git 源;该包通过 NPM 解析辅助程序,对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,准备相邻的 skill技能并加载全部三类贡献。一个刻意设为失败的宿主 `PATH` 命令可以证明,该生命周期选中的是依赖内的可执行文件。验收还要求经过注册表解析并检查不可变的已准备缓存,因此恢复宿主注入的辅助程序也无法通过。
## 考虑过的替代方案
**从正在运行的 DSH 安装注入 `dsh-plugin-prepare`。** 拒绝,因为这会让 manifest 不完整的 repository 包通过,并测试 NPM 消费方无法复现的纯宿主路径。
**把源 fixture 本身发布到 NPM。** 拒绝,因为产品契约明确要求 DSH 插件仍托管在 Git只有可复用的准备辅助程序是 NPM 依赖。
**在每次 CI 运行中创建新的私有 GitHub 仓库。** 拒绝,因为 PR 仓库的精确 head SHA 已是经过认证的真实私有 Git remote。每次运行的仓库变更会增加凭据、清理和最终一致性失败模式却不改变获取路径。
**在 `RepositoryCache` 安装所选包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成 packlist 前完成。
**在 DSH 中克隆 GitHub 仓库并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定包管理器负责的 ref 解析、子目录选择、依赖安装、packlist 行为和缓存完整性。
## 后果
- 仓库作者可以把 `.dsh-plugin` 包、TypeScript 源码、skill 与 MCP 定义提交到 GitHub而无需把该插件包发布到 NPM。该包必须声明已发布的准备依赖。
- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。
- 创作格式使用 `prepack` 而不是 `prepare`。其中可以包含任意包自有构建步骤,但必须调用依赖提供的辅助程序;依赖或生命周期元数据缺失时,会在缓存 generation 可用前失败。
- pnpm 仓库中的所选包按自身 manifest 安装,而不继承外层 workspace。它不能依赖仅由 workspace 提升而可见的包;普通注册表依赖和相对 `file:` 依赖仍是包自有输入。
- 精确源字符串标识不可变缓存 generation改变 ref 或源配置会选择另一个 generation。
- 包依赖、编译、准备和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。
## 测试
`packages/ui/app-boot/tests/repository-cache.spec.ts` 会通过本地 Git 子路径运行一个未列入源仓库根 pnpm lockfile 的包,并要求相对 `file:` 依赖同时提供构建命令与 `dsh-plugin-prepare`;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/cordis/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据与准备输出。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式契约。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:模拟发布的辅助程序包、作业本地 NPM 注册表、全新 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM大语言模型请求观测以及已准备缓存检查。

View File

@@ -22,6 +22,7 @@
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "0.0.1",
"cordis": "4.0.0-rc.7",
"tsdown": "0.22.2",
"typescript": "6.0.3"

View File

@@ -1,7 +1,9 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { execa } from 'execa'
@@ -9,12 +11,121 @@ import { describe, expect, it } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
const repositoryPluginPackage = join(repoRoot, 'packages/cordis/repository-plugin')
const releasePackageNames = new Set(globSync([
'vendor/*/package.json',
'packages/*/*/package.json',
'apps/*/package.json',
], { cwd: repoRoot }).map((filename) => {
const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record<string, unknown>
if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`)
return manifest.name
}))
const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE
const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1'
const enabled = required || source !== undefined
interface PublishedPackageRegistry {
url: string
requests: string[]
close(): Promise<void>
}
function publishedManifest(): Record<string, unknown> {
const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record<string, unknown>
const version = manifest.version
if (typeof version !== 'string') throw new Error('repository Plugin package version is missing')
Reflect.deleteProperty(manifest, 'private')
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
const dependencies = manifest[field]
if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue
const entries = dependencies as Record<string, unknown>
for (const name of Object.keys(entries)) {
if (releasePackageNames.has(name)) {
entries[name] = version
}
}
}
return manifest
}
async function startPublishedPackageRegistry(root: string): Promise<PublishedPackageRegistry> {
const staging = join(root, 'published-repository-plugin')
const artifacts = join(root, 'npm-registry-artifacts')
mkdirSync(staging)
mkdirSync(artifacts)
cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true })
for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) {
cpSync(join(repositoryPluginPackage, filename), join(staging, filename))
}
cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE'))
const manifest = publishedManifest()
writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], {
cwd: staging,
reject: false,
})
if (packed.exitCode !== 0) {
throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`)
}
const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz'))
if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`)
const tarball = readFileSync(join(artifacts, tarballs[0]!))
const name = manifest.name as string
const version = manifest.version as string
const requests: string[] = []
let registryUrl = ''
const server = createServer((request, response) => {
const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname)
requests.push(`${request.method ?? 'GET'} ${path}`)
if (path === `/${name}`) {
const metadata = {
name,
'dist-tags': { latest: version },
versions: {
[version]: {
...manifest,
dist: {
tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`,
shasum: createHash('sha1').update(tarball).digest('hex'),
integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`,
},
},
},
}
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify(metadata))
return
}
if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) {
response.writeHead(200, {
'content-type': 'application/octet-stream',
'content-length': String(tarball.length),
})
response.end(tarball)
return
}
response.writeHead(404, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'not found' }))
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('simulated npm registry did not expose a TCP address')
registryUrl = `http://127.0.0.1:${address.port}/`
return {
url: registryUrl,
requests,
close: () => new Promise<void>((resolve, reject) => {
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
}),
}
}
describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => {
it('installs, builds, and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => {
it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => {
expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true)
expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch(
/^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u,
@@ -29,6 +140,17 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () =>
successText: 'trusted GitHub repository package reached dsh run',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-'))
const registry = await startPublishedPackageRegistry(home)
const npmrc = join(home, 'npmrc')
writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`)
const hostBin = join(home, 'host-bin')
mkdirSync(hostBin)
writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [
'#!/bin/sh',
'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2',
'exit 91',
'',
].join('\n'), { mode: 0o700 })
const patch = join(home, 'github-repository-plugin.cordis.patch.yml')
writeFileSync(patch, [
'- id: repository-plugins',
@@ -59,6 +181,8 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () =>
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: apiKey,
DEEPSEEK_BASE_URL: server.baseURL,
NPM_CONFIG_USERCONFIG: npmrc,
PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`,
},
})
if (result.timedOut) {
@@ -68,6 +192,8 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () =>
expect(result.stdout).toBe('trusted GitHub repository package reached dsh run')
expect(server.requests).toHaveLength(2)
const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}`
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin')
expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz')
const firstRequest = JSON.stringify(server.requests[0]!.body)
const secondRequest = JSON.stringify(server.requests[1]!.body)
expect(firstRequest, runtimeDiagnostic).toContain(
@@ -98,6 +224,7 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () =>
'@modelcontextprotocol/sdk': '1.29.0',
},
devDependencies: {
'@deepseek-ai/dsh-repository-plugin': '0.0.1',
cordis: '4.0.0-rc.7',
tsdown: '0.22.2',
typescript: '6.0.3',
@@ -117,6 +244,7 @@ describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () =>
expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
} finally {
await server.close()
await registry.close()
rmSync(home, { recursive: true, force: true })
}
}, 190_000)

View File

@@ -1186,7 +1186,7 @@ export interface Config {
}
```
Source: [`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts)
Source: [`packages/self-modification/repository-plugin/src/index.ts:43`](../packages/self-modification/repository-plugin/src/index.ts)
## `@deepseek-ai/dsh-sandbox-local`

View File

@@ -710,6 +710,9 @@
"project": [
"src/**/*.ts"
],
"ignoreDependencies": [
"@deepseek-ai/dsh-repository-plugin"
],
"ignoreBinaries": [
"dsh-plugin-prepare"
]

View File

@@ -115,24 +115,9 @@ describe('RepositoryCache', () => {
it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
const executableDirectory = join(root, 'bin')
await mkdir(executableDirectory)
await writeFile(join(executableDirectory, 'dsh-plugin-prepare'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(executableDirectory, 'dsh-plugin-prepare.cmd'), [
'@echo off',
'node "%~dp0\\dsh-plugin-prepare" %*',
'',
].join('\r\n'))
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, 'build-helper'), { recursive: true })
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
@@ -160,12 +145,29 @@ describe('RepositoryCache', () => {
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({
name: 'repository-prepare-helper',
version: '1.0.0',
bin: { 'dsh-plugin-prepare': 'index.js' },
})}\n`)
await writeFile(join(repository, 'prepare-helper', 'index.js'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'), { mode: 0o700 })
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: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
devDependencies: { 'repository-build-helper': 'file:../build-helper' },
devDependencies: {
'repository-build-helper': 'file:../build-helper',
'repository-prepare-helper': 'file:../prepare-helper',
},
dsh: { skills: ['../skills'] },
})}\n`)
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
@@ -180,9 +182,7 @@ describe('RepositoryCache', () => {
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
const installed = await new RepositoryCache(join(root, 'cache'), {
executableDirectories: [executableDirectory],
}).resolve(specifier)
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
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')

View File

@@ -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/self-modification/repository-plugin/README.md
README.md: e10907408a98cd470ae935b327ae44322f2e54a7
README.zh.md: b8ba7dccd929574cc5aa1b7a3d68bf4f1e2f80e4
README.md: 3a6478d5cca05684e80ce0854cb6e0b5d2f88bd4
README.zh.md: 274d11c96cb61d4d9fa4b837433994248c94bff4

View File

@@ -27,12 +27,13 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1",
"typescript": "6.0.3"
}
}
```
`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. DSH supplies only that helper command from its installed runtime: the package declares and runs its own compiler, runtime dependencies, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry.
`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. The package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency so its published executable is available to that lifecycle. DSH does not inject the helper: the repository package declares and runs its own compiler, runtime dependencies, preparation helper, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry.
`dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
@@ -59,7 +60,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v
## Preparation
During exact Git installation, DSH places temporary host-owned `pnpm` and `dsh-plugin-prepare` commands on the isolated package lifecycle `PATH`; neither command is fetched from the repository. The pnpm command reinvokes DSH's pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to install, build, or prepare fails before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md).
During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared `@deepseek-ai/dsh-repository-plugin` dependency. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md).
## Runtime composition

View File

@@ -27,12 +27,13 @@
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1",
"typescript": "6.0.3"
}
}
```
`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。DSH 已安装的运行时只提供该辅助命令:包自行声明并运行编译器、运行时依赖其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace因此必须声明所需的每项依赖不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript也不推断包入口。
`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。包将 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖使该生命周期可以使用其已发布的可执行文件。DSH 不会注入辅助程序repository 包自行声明并运行编译器、运行时依赖、准备辅助程序及其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace因此必须声明所需的每项依赖不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript也不推断包入口。
`dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export并自行拥有常规的 `name``inject``Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。
@@ -59,7 +60,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有
## 准备阶段
安装精确指定的 Git 源时DSH 会把临时的宿主自有 `pnpm``dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;两个命令都不从 repository 获取。该 pnpm 命令会以 `--ignore-workspace` 重新调用 DSH 锁定的 pnpm因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。安装、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。
安装精确指定的 Git 源时DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从已声明的 `@deepseek-ai/dsh-repository-plugin` 依赖取得 `dsh-plugin-prepare`。该包把 CordisDSH 运行时对等依赖peer dependency标为可选因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。
## 运行时组合

View File

@@ -36,6 +36,26 @@
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@cordisjs/plugin-loader": {
"optional": true
},
"@deepseek-ai/dsh-invariants": {
"optional": true
},
"@deepseek-ai/dsh-mcp-client": {
"optional": true
},
"@deepseek-ai/dsh-paths": {
"optional": true
},
"@deepseek-ai/dsh-skill-local": {
"optional": true
},
"cordis": {
"optional": true
}
},
"dependencies": {
"zod": "^4.4.3"
},

View File

@@ -14,11 +14,11 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
/** Loader builtin used by every generated repository wrapper. */
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
/** Host-owned command that repository package `prepack` lifecycles must invoke. */
/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
/**
* Whether a package lifecycle declaration names the host preparation helper.
* Whether a package lifecycle declaration names the preparation dependency's helper.
* @param script - package-authored lifecycle command.
* @returns true when the required helper command is present.
*/

View File

@@ -20,7 +20,6 @@ import {
} from './format.ts'
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
import {
createRepositoryPrepareCommand,
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
@@ -131,24 +130,17 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
if (new Set(repositories).size !== repositories.length) {
throw new Error('repository sources must resolve to unique exact specifiers')
}
const prepareCommand = repositories.length === 0 ? undefined : await createRepositoryPrepareCommand()
try {
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir), {
executableDirectories: prepareCommand === undefined ? [] : [prepareCommand.directory],
})
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)
}
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')
} finally {
await prepareCommand?.dispose()
}
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
}

View File

@@ -3,10 +3,9 @@
* @module
*/
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
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'
@@ -24,56 +23,6 @@ const FIBER_ACTIVE = 2 as FiberState.ACTIVE
/** Directory under the Harness home containing immutable repository generations. */
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
/** Temporary host command supplied to repository package lifecycle scripts. */
export interface RepositoryPrepareCommand {
/** Absolute directory to prepend to the isolated install's executable search path. */
directory: string
/** Remove the temporary command directory. */
dispose(): Promise<void>
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
function batchQuote(value: string): string {
return `"${value.replaceAll('%', '%%')}"`
}
/**
* Materialize the DSH-owned prepare executable used only while pnpm packs Git source.
* @returns a command directory and its idempotent cleanup operation.
*/
export async function createRepositoryPrepareCommand(): Promise<RepositoryPrepareCommand> {
const directory = await mkdtemp(join(tmpdir(), 'dsh-repository-plugin-bin-'))
const target = fileURLToPath(new URL('../lib/bin.js', import.meta.url))
try {
await Promise.all([
writeFile(join(directory, REPOSITORY_PLUGIN_PREPARE_COMMAND), [
'#!/bin/sh',
`exec ${shellQuote(process.execPath)} ${shellQuote(target)} "$@"`,
'',
].join('\n'), { mode: 0o700 }),
writeFile(join(directory, `${REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), [
'@echo off',
`${batchQuote(process.execPath)} ${batchQuote(target)} %*`,
'',
].join('\r\n'), { mode: 0o700 }),
])
} catch (cause) {
/* v8 ignore next -- requires a host filesystem failure after mkdtemp; cleanup semantics are the contract under test. */
await rm(directory, { recursive: true, force: true })
/* v8 ignore next -- preserves that unstageable host failure after best-effort cleanup. */
throw cause
}
return {
directory,
async dispose() {
await rm(directory, { recursive: true, force: true })
},
}
}
// 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

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
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'
@@ -14,7 +14,6 @@ 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 {
createRepositoryPrepareCommand,
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
@@ -87,7 +86,7 @@ describe('dsh-plugin-prepare', () => {
.resolves.toContain('mcp.expo.dev')
})
it('preserves a compiled package entry and accepts a build before the host prepare command', async () => {
it('preserves a compiled package entry and accepts a build before the package prepare command', async () => {
const root = await temporaryDirectory('compiled-entry')
const directory = await writePlugin(root, 'compiled-entry-fixture', {
entry: './lib/plugin.mjs',
@@ -406,17 +405,6 @@ describe('prepared repository plugin Loader composition', () => {
})
describe('configured GitHub repository sources', () => {
it('creates host-owned prepare commands and removes them idempotently', async () => {
const command = await createRepositoryPrepareCommand()
expect(await readFile(join(command.directory, RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND), 'utf8'))
.toContain(process.execPath)
expect(await readFile(join(command.directory, `${RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), 'utf8'))
.toContain(process.execPath)
await command.dispose()
await command.dispose()
await expect(stat(command.directory)).rejects.toMatchObject({ code: 'ENOENT' })
})
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)
@@ -608,7 +596,7 @@ describe('configured GitHub repository sources', () => {
await ctx.fiber.dispose()
})
it('rejects an installed source whose prepack omits the host prepare command', async () => {
it('rejects an installed source whose prepack omits the package prepare command', async () => {
const root = await temporaryDirectory('installed-skipped-prepare')
await writeFile(join(root, 'package.json'), JSON.stringify({
name: 'installed-skipped-prepare',

2
vendor/README.md vendored
View File

@@ -39,7 +39,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, undo changes and additions on failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`.
10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. Callers may prepend host-owned executable directories to the isolated package lifecycle `PATH`; all paths are resolved before the child starts. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. Temporary command directories are removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and requires its own build dependency.
10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. A transaction-owned `pnpm` wrapper makes pnpm's nested Git-package install reinvoke the same bundled entry with `--ignore-workspace`, so the selected package installs its own manifest dependencies instead of joining an enclosing source workspace. The temporary command directory is removed after the child settles. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile and obtains both its build and prepare commands from declared dependencies.
11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`.

View File

@@ -26,8 +26,6 @@ export type RepositoryInstall = (directory: string) => Promise<void>
export interface RepositoryCacheOptions {
/** Override the isolated package installation boundary. */
install?: RepositoryInstall
/** Command directories resolved absolutely and prepended to package lifecycle `PATH`. */
executableDirectories?: readonly string[]
}
interface CacheMarker {
@@ -38,14 +36,13 @@ function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.
return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name)))
}
function installEnvironment(executableDirectories: readonly string[]): NodeJS.ProcessEnv {
function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv {
const scrubbed = scrubEnvironment()
if (executableDirectories.length === 0) return scrubbed
const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1]
const withoutPath = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => name.toUpperCase() !== 'PATH'))
return {
...withoutPath,
PATH: [...executableDirectories, ...(path === undefined ? [] : [path])].join(delimiter),
PATH: [commandDirectory, ...(path === undefined ? [] : [path])].join(delimiter),
}
}
@@ -62,10 +59,7 @@ function appendOutput(current: string, chunk: Uint8Array): string {
return combined.length <= MAX_ERROR_OUTPUT ? combined : combined.slice(-MAX_ERROR_OUTPUT)
}
async function installWithBundledPnpm(
directory: string,
executableDirectories: readonly string[],
): Promise<void> {
async function installWithBundledPnpm(directory: string): Promise<void> {
const require = createRequire(import.meta.url)
const pnpmManifest = require.resolve('pnpm')
const pnpmBin = join(dirname(pnpmManifest), 'bin', 'pnpm.mjs')
@@ -92,7 +86,7 @@ async function installWithBundledPnpm(
'--reporter=append-only',
], {
cwd: directory,
env: installEnvironment([commandDirectory, ...executableDirectories]),
env: installEnvironment(commandDirectory),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
})
@@ -174,12 +168,11 @@ export class RepositoryCache {
/**
* @param directory - caller-owned persistent cache root.
* @param options - isolated installer override and lifecycle command directories.
* @param options - isolated installer override.
*/
constructor(directory: string, options: RepositoryCacheOptions = {}) {
this.directory = resolve(directory)
const executableDirectories = (options.executableDirectories ?? []).map(entry => resolve(entry))
this.install = options.install ?? (staging => installWithBundledPnpm(staging, executableDirectories))
this.install = options.install ?? installWithBundledPnpm
}
/**