Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/subsystems/attachment.i18n.yaml # docs/subsystems/attachment.md # docs/subsystems/attachment.zh.md # docs/subsystems/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/runtime/package.json # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/self-modification/tool-cordis/src/api-catalog.ts # pnpm-lock.yaml # scripts/type-equiv.manifest.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 apps/cli/README.md
|
||||
README.md: 12108fcbff4e649d0bcb3e01e688fa334ab91b14
|
||||
README.zh.md: 9518feed1d5d40c3e5ec2d346b929118ccc08810
|
||||
README.md: dd29f7fc03a783079ea3194de99589c1f545be5b
|
||||
README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc
|
||||
|
||||
@@ -9,7 +9,7 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | Run one fresh persisted session, print the final answer, and exit; the profile defaults to `headless`. |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. |
|
||||
| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | 运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`。 |
|
||||
| `dsh run [--profile <name>] [--patch <path>...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 |
|
||||
| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
@@ -23,4 +23,4 @@ profile 目录包含一个 `package.json`(树外插件依赖,加上 profile
|
||||
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。
|
||||
|
||||
@@ -32,6 +32,8 @@ flowchart LR
|
||||
cfg --> plugin_dsh_base_user_interaction
|
||||
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
|
||||
cfg --> plugin_dsh_base_agent
|
||||
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
|
||||
cfg --> plugin_dsh_base_agent_default_model
|
||||
plugin_dsh_base_tasks["tasks<br/>@deepseek-ai/dsh-tasks-local"]
|
||||
cfg --> plugin_dsh_base_tasks
|
||||
plugin_dsh_base_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
|
||||
@@ -176,6 +178,7 @@ flowchart LR
|
||||
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
|
||||
| `user-interaction` | `@deepseek-ai/dsh-user-interaction` |
|
||||
| `agent` | `@deepseek-ai/dsh-agent` |
|
||||
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
|
||||
| `tasks` | `@deepseek-ai/dsh-tasks-local` |
|
||||
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
|
||||
| `settings` | `@deepseek-ai/dsh-settings-local` |
|
||||
|
||||
@@ -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 apps/cli/reference/README.md
|
||||
README.md: 496cecdb64e3254a2a77690f55f760b4cd90b521
|
||||
README.zh.md: 4673bf764347307a9b91e2a5474a8439cf67b481
|
||||
README.md: bb3e1d77f00e03f6a21f4da3c04994f208e84293
|
||||
README.zh.md: 3802f940ef6663ec215cf09293a9edda5ea4c512
|
||||
|
||||
@@ -10,7 +10,7 @@ This reference defines the profile, one-shot run, web-alias, plugin-management,
|
||||
|
||||
Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
|
||||
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + web-app + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile <name> "<task>"` command instead of reaching the row's raw required-field error.
|
||||
|
||||
@@ -21,13 +21,13 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
|
||||
## One-shot run
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row.
|
||||
|
||||
The launcher patches the task text into the runner row, which drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. At the idle boundary, the runner waits until its mux consumer has observed the session's final event sequence before deriving that output and exit reason. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser.
|
||||
The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
|
||||
|
||||
## Plugin management
|
||||
|
||||
@@ -69,7 +69,7 @@ The base bundle mounts the native DeepSeek adapter, settings and credential prov
|
||||
|
||||
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.
|
||||
|
||||
The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
## Source launcher
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。
|
||||
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile <name> "<task>"`,而不会触发该行原始的必填字段错误。
|
||||
|
||||
@@ -21,13 +21,13 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
|
||||
## 一次性运行
|
||||
|
||||
`dsh run [--profile <name>] [--patch <path>...] <task...>` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。
|
||||
|
||||
启动器把任务文本 patch 进运行器行,运行器再通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。到达 idle 边界时,运行器会等到 mux 消费方观察到会话的最终事件序号,再生成输出与退出原因。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。
|
||||
启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
|
||||
|
||||
## 插件管理
|
||||
|
||||
@@ -61,7 +61,7 @@ dsh web --dump-config
|
||||
|
||||
`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。
|
||||
|
||||
`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该契约的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。
|
||||
`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。
|
||||
|
||||
## 共享部署行为
|
||||
|
||||
@@ -69,7 +69,7 @@ dsh web --dump-config
|
||||
|
||||
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。
|
||||
|
||||
空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 契约](../../../packages/cordis/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 约定](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
## 源码启动器
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Config-dump entry for `dsh --profile <name> --dump-config`: compose the
|
||||
* profile's patch layers through the include plugin's patch algorithm without
|
||||
* booting or evaluating `!!js`, with one provenance layer per bundle, the
|
||||
* booting or evaluating `!!js`, with one source layer per bundle, the
|
||||
* profile's own patch file, and each `--patch` overlay.
|
||||
* @module @deepseek-ai/dsh/dump-config
|
||||
*/
|
||||
@@ -20,7 +20,7 @@ const NAME = 'dsh'
|
||||
|
||||
/* v8 ignore start -- built-bin acceptance drives this boot-free dispatch */
|
||||
/**
|
||||
* Print a profile composition with provenance comments.
|
||||
* Print a profile composition with comments naming each source file and patch layer.
|
||||
* @param profile - the profile name.
|
||||
* @param defaultOnly - omit the profile's user layer and `--patch` overlays
|
||||
* (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import { FiberState, type Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import {
|
||||
boot,
|
||||
@@ -168,6 +168,11 @@ export interface RunProfileOptions {
|
||||
environment: EnvironmentSnapshot
|
||||
}
|
||||
|
||||
/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
|
||||
function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
|
||||
if (!signal.aborted) throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one profile invocation end to end and leave process lifetime to the
|
||||
* mounted plugins (or to the one-shot runner when `task` is present).
|
||||
@@ -196,11 +201,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
|
||||
const app: { current?: Context } = {}
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
const signalShutdown = new AbortController()
|
||||
const interrupt = (code: number): void => {
|
||||
signalShutdown.abort()
|
||||
shutdown.interrupt(code)
|
||||
}
|
||||
// Signals own teardown throughout the startup window, not only after boot()
|
||||
// settles: an inserted front door can publish readiness before sibling rows
|
||||
// finish mounting.
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(options.task === undefined ? 0 : 143) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) })
|
||||
process.on('SIGINT', () => { interrupt(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
@@ -243,33 +253,41 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
await options.prepare?.(hostCtx, composed.rows)
|
||||
})
|
||||
app.current = ctx
|
||||
// A surface can dispose the whole tree while startup was still in flight
|
||||
// (early SIGTERM); the Loader service goes with it and there is nothing to
|
||||
// keep live.
|
||||
if (watchProfilePatch && ctx.get('loader') !== undefined) {
|
||||
// Config-only HMR for the live profile patch layer: the web bundle
|
||||
// disables the shared module-reload `hmr` row (its reload lifecycle is
|
||||
// untested), so when the composition leaves no HMR service, mount a
|
||||
// watch-only instance with no module roots — cordis.patch.yml edits stay
|
||||
// live on every long-lived surface. A silent skip would break the
|
||||
// documented hot-reload contract. HMR injects the timer service, which a
|
||||
// bare custom profile may not mount either.
|
||||
if (ctx.get('hmr') === undefined) {
|
||||
if (ctx.get('timer') === undefined) {
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
|
||||
// A surface can dispose the whole tree while startup or this post-boot
|
||||
// watcher setup is still in flight. Loader presence and fiber state own
|
||||
// liveness; the local signal fact distinguishes that expected exit race
|
||||
// from a real HMR error.
|
||||
if (watchProfilePatch
|
||||
&& !signalShutdown.signal.aborted
|
||||
&& ctx.fiber.state === FiberState.ACTIVE
|
||||
&& ctx.get('loader') !== undefined) {
|
||||
try {
|
||||
// Config-only HMR for the live profile patch layer: the web bundle
|
||||
// disables the shared module-reload `hmr` row (its reload lifecycle is
|
||||
// untested), so when the composition leaves no HMR service, mount a
|
||||
// watch-only instance with no module roots — cordis.patch.yml edits stay
|
||||
// live on every long-lived surface. A silent skip would break the
|
||||
// documented hot-reload contract. HMR injects the timer service, which a
|
||||
// bare custom profile may not mount either.
|
||||
if (ctx.get('hmr') === undefined) {
|
||||
if (ctx.get('timer') === undefined) {
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-timer' })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
}
|
||||
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
} catch (error) {
|
||||
suppressSignalShutdownError(signalShutdown.signal, error)
|
||||
}
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
}
|
||||
return { ctx, shutdown }
|
||||
}
|
||||
|
||||
@@ -64,11 +64,11 @@ describe('parseDshArgs', () => {
|
||||
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
|
||||
})
|
||||
|
||||
it('rejects missing profile, removed flags, and contradictory inputs', () => {
|
||||
it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => {
|
||||
expect(exitCode([])).toBe(1)
|
||||
expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // removed
|
||||
expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar
|
||||
expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run`
|
||||
expect(exitCode(['run'])).toBe(1)
|
||||
expect(exitCode(['run', ''])).toBe(1)
|
||||
|
||||
@@ -49,6 +49,7 @@ interface ProfileLifecycleFixture {
|
||||
ready: string
|
||||
settled: string
|
||||
disposed: string
|
||||
interrupt: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,16 +62,23 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
const ready = join(home, 'ready')
|
||||
const settled = join(home, 'settled')
|
||||
const disposed = join(home, 'disposed')
|
||||
const interrupt = join(home, 'interrupt')
|
||||
const bundleDir = join(home, 'lifecycle-bundle')
|
||||
mkdirSync(bundleDir, { recursive: true })
|
||||
writeFileSync(join(bundleDir, 'plugin.mjs'), [
|
||||
"import { writeFileSync } from 'node:fs'",
|
||||
"import { existsSync, writeFileSync } from 'node:fs'",
|
||||
"import { join } from 'node:path'",
|
||||
"export const name = 'profile-lifecycle-fixture'",
|
||||
'export function apply(ctx, config = {}) {',
|
||||
' let active = true',
|
||||
' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
|
||||
' const heartbeat = setInterval(() => {}, 1000)',
|
||||
' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
|
||||
' let interrupted = false',
|
||||
' const heartbeat = setInterval(() => {',
|
||||
' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
|
||||
' interrupted = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
' // Echo the mounted generation so the hot-reload e2e can assert both an',
|
||||
' // applied override and its removal reverting to this bundle default.',
|
||||
" writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
|
||||
@@ -118,7 +126,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
|
||||
for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
|
||||
writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
|
||||
}
|
||||
return { home, ready, settled, disposed }
|
||||
return { home, ready, settled, disposed, interrupt }
|
||||
}
|
||||
|
||||
function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
@@ -131,10 +139,22 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
|
||||
RAW_READY_FILE: fixture.ready,
|
||||
RAW_SETTLED_FILE: fixture.settled,
|
||||
RAW_DISPOSED_FILE: fixture.disposed,
|
||||
RAW_INTERRUPT_FILE: fixture.interrupt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function requestProfileShutdown(
|
||||
child: ReturnType<typeof startProfileLifecycle>,
|
||||
fixture: ProfileLifecycleFixture,
|
||||
): void {
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(fixture.interrupt, 'interrupt')
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
|
||||
function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
const pluginFile = join(project, 'environment-probe.mjs')
|
||||
writeFileSync(pluginFile, [
|
||||
@@ -152,7 +172,8 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
" if (chunk.type === 'text-delta') text += chunk.text",
|
||||
' }',
|
||||
' process.stdout.write(`${text}\\n`)',
|
||||
" process.kill(process.pid, 'SIGTERM')",
|
||||
" if (process.platform === 'win32') process.emit('SIGTERM')",
|
||||
" else process.kill(process.pid, 'SIGTERM')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
@@ -174,7 +195,7 @@ function createEnvironmentProbeProfile(home: string, project: string): void {
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
it('requires --profile and rejects removed commands', async () => {
|
||||
it('requires --profile and rejects inputs outside the current grammar', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
expect(bare.stdout).toBe('')
|
||||
@@ -185,8 +206,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(help.stdout).toContain('dsh run "run the tests"')
|
||||
expect(help.stdout).toContain('dsh plugin --profile')
|
||||
expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
|
||||
for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(removed)
|
||||
for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) {
|
||||
const result = await runBuiltBin(outsideGrammar)
|
||||
expect(result.code).toBe(1)
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -222,7 +243,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('published dsh run reached the mock')
|
||||
expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u)
|
||||
expect(result.stderr).toBe('')
|
||||
expect(server.requests.length).toBeGreaterThan(0)
|
||||
expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
|
||||
expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
|
||||
@@ -297,9 +318,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}, 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
// refresh drain: dsh exited 13 with no diagnostic instead of settling
|
||||
// An HMR main-watcher initial scan that refreshes the include
|
||||
// mid-initial-apply deadlocks the failing apply's rollback against the
|
||||
// refresh drain: dsh exits 13 with no diagnostic instead of settling
|
||||
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
||||
try {
|
||||
@@ -321,9 +342,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
const child = startProfileLifecycle(fixture)
|
||||
try {
|
||||
await waitForFile(fixture.ready)
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -373,9 +394,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(configFile, 'utf8')).toBe('home')
|
||||
child.kill('SIGTERM')
|
||||
requestProfileShutdown(child, fixture)
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(existsSync(fixture.disposed)).toBe(true)
|
||||
} finally {
|
||||
@@ -470,6 +491,20 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
|
||||
}, 30_000)
|
||||
|
||||
it('prints a headless profile with no Host, HTTP, or browser rows', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(
|
||||
['--profile', 'headless', '--dump-default-config'],
|
||||
{ DSH_HOME: home },
|
||||
)
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).toBe('')
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'")
|
||||
expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
|
||||
expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-")
|
||||
}, 30_000)
|
||||
|
||||
it('composes the profile user layer and a --patch overlay in order', async () => {
|
||||
// Auto-init the web profile first, then write its user layer.
|
||||
const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
|
||||
@@ -504,7 +539,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(code).toBe(0)
|
||||
expect(stdout).toContain('provider: configured-provider')
|
||||
expect(stdout).not.toContain('personal-provider')
|
||||
// Both layers patched the row; provenance lists them in application order.
|
||||
// Both layers patched the row; the comment lists them in application order.
|
||||
expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
|
||||
expect(stderr).toContain('patch: entry "absent-row" not found')
|
||||
}, 30_000)
|
||||
|
||||
10
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json
vendored
Normal file
10
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"github_repository": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"lib/mcp-server.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json
vendored
Normal file
30
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "dsh-github-repository-plugin-e2e-fixture",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"lib",
|
||||
"dsh-plugin.mjs",
|
||||
"dsh-plugin-assets"
|
||||
],
|
||||
"scripts": {
|
||||
"prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare"
|
||||
},
|
||||
"dsh": {
|
||||
"skills": [
|
||||
"../skills"
|
||||
],
|
||||
"mcpServers": "./.mcp.json",
|
||||
"entry": "./lib/plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
19
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts
vendored
Normal file
19
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
|
||||
// The repository root's linter cannot resolve this independently installed
|
||||
// Git-package dependency; the package's prepack tsc validates the SDK types.
|
||||
/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */
|
||||
const server = new McpServer({
|
||||
name: 'github-repository-plugin-e2e',
|
||||
version: '0.0.0',
|
||||
})
|
||||
|
||||
server.registerTool('proof', {
|
||||
description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.',
|
||||
inputSchema: {},
|
||||
}, async () => ({
|
||||
content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }],
|
||||
}))
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
59
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts
vendored
Normal file
59
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
const PROOF_TOOL_NAME = 'mcp__github_repository__proof'
|
||||
|
||||
interface TextBlock {
|
||||
readonly type: 'text'
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
interface ToolExecution {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
interface ToolResult {
|
||||
readonly isError: boolean
|
||||
readonly content: readonly TextBlock[]
|
||||
}
|
||||
|
||||
type PostDecision =
|
||||
| { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] }
|
||||
| { readonly kind: 'block'; readonly feedback: readonly TextBlock[] }
|
||||
|
||||
type PostListener = (
|
||||
execution: ToolExecution,
|
||||
result: ToolResult,
|
||||
next: () => Promise<PostDecision>,
|
||||
) => Promise<PostDecision>
|
||||
|
||||
type DshContext = Context & {
|
||||
on(event: 'tools/post-execute', listener: PostListener): () => void
|
||||
}
|
||||
|
||||
/** Cordis plugin name used by the repository acceptance fixture. */
|
||||
export const name = 'github-repository-typescript-proof'
|
||||
|
||||
/** DSH tool registry required by the post-execute contribution. */
|
||||
export const inject = ['tools']
|
||||
|
||||
/**
|
||||
* Append a marker after the repository MCP proof tool succeeds.
|
||||
* @param ctx - trusted DSH Cordis context supplied to the repository package.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const dsh = ctx as DshContext
|
||||
dsh.on('tools/post-execute', async (execution, result, next): Promise<PostDecision> => {
|
||||
const decision = await next()
|
||||
if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) {
|
||||
return decision
|
||||
}
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [
|
||||
...(decision.content ?? result.content),
|
||||
{ type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' },
|
||||
],
|
||||
...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts },
|
||||
}
|
||||
})
|
||||
}
|
||||
13
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json
vendored
Normal file
13
apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
6
apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md
vendored
Normal file
6
apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: github-source-proof
|
||||
description: Proves that dsh installed a private repository Plugin from an exact GitHub source.
|
||||
---
|
||||
|
||||
This skill exists only in the GitHub repository source fixture.
|
||||
1
apps/cli/tests/fixtures/never-dispose.mjs
vendored
1
apps/cli/tests/fixtures/never-dispose.mjs
vendored
@@ -8,6 +8,7 @@ import { existsSync } from 'node:fs'
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
process.stderr.write('dsh-test: never-dispose ready\n')
|
||||
ctx.effect(() => async () => {
|
||||
clearInterval(keepAlive)
|
||||
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
|
||||
|
||||
256
apps/cli/tests/github-repository-plugin.built.e2e.ts
Normal file
256
apps/cli/tests/github-repository-plugin.built.e2e.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
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 { delimiter, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
|
||||
import { execa } from 'execa'
|
||||
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/self-modification/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 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,
|
||||
)
|
||||
|
||||
const apiKey = 'github-repository-plugin-e2e-key'
|
||||
const server = await startMockLlmServer({
|
||||
sequence: ['tool_call_success', 'success'],
|
||||
apiKey,
|
||||
toolName: 'mcp__github_repository__proof',
|
||||
toolArguments: '{}',
|
||||
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',
|
||||
' config:',
|
||||
' repositories:',
|
||||
` - ${JSON.stringify(source)}`,
|
||||
'- id: session-title-llm',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'run',
|
||||
'--patch',
|
||||
patch,
|
||||
'prove the private GitHub repository Plugin is active',
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
input: '',
|
||||
timeout: 180_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: apiKey,
|
||||
DEEPSEEK_BASE_URL: server.baseURL,
|
||||
NPM_CONFIG_USERCONFIG: npmrc,
|
||||
// A warm runner cache could satisfy the exact tarball without
|
||||
// contacting this test's registry, which would stop proving the
|
||||
// unpublished package was installed through the simulated release.
|
||||
PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'),
|
||||
PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'),
|
||||
PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`,
|
||||
},
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0)
|
||||
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(
|
||||
'Proves that dsh installed a private repository Plugin from an exact GitHub source.',
|
||||
)
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof')
|
||||
expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
|
||||
const cacheRoot = join(home, 'cache', 'repository-plugins')
|
||||
const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory())
|
||||
expect(generations).toHaveLength(1)
|
||||
const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository')
|
||||
const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'dsh-github-repository-plugin-e2e-fixture',
|
||||
private: true,
|
||||
scripts: {
|
||||
prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare',
|
||||
},
|
||||
dsh: {
|
||||
skills: ['../skills'],
|
||||
mcpServers: './.mcp.json',
|
||||
entry: './lib/plugin.mjs',
|
||||
},
|
||||
dependencies: {
|
||||
'@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',
|
||||
},
|
||||
})
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8'))
|
||||
.toContain('This skill exists only in the GitHub repository source fixture.')
|
||||
expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs')
|
||||
expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY')
|
||||
expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY')
|
||||
expect(existsSync(join(installed, 'src'))).toBe(false)
|
||||
const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs'))
|
||||
expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true)
|
||||
const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8')
|
||||
expect(wrapper).toContain('dsh-repository-plugin')
|
||||
expect(wrapper).toContain('await import(manifest.entry)')
|
||||
expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
|
||||
} finally {
|
||||
await server.close()
|
||||
await registry.close()
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
}, 190_000)
|
||||
})
|
||||
@@ -22,7 +22,7 @@ if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
|
||||
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
|
||||
markers = [b"dsh-test: never-dispose ready", b"dsh-test: never-dispose started"]
|
||||
output = bytearray()
|
||||
marker_index = 0
|
||||
deadline = time.monotonic() + float(timeout_seconds)
|
||||
@@ -73,7 +73,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
name: 'dsh-profile-headless',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'] } },
|
||||
}, undefined, 2))
|
||||
await writeFile(join(profileDir, 'cordis.patch.yml'), [
|
||||
'- insert:',
|
||||
@@ -124,7 +124,8 @@ async function runHeadlessPtySmoke(): Promise<string> {
|
||||
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
|
||||
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
|
||||
const output = await runHeadlessPtySmoke()
|
||||
expect(output).toContain('dsh: observing at ')
|
||||
expect(output).not.toContain('dsh: observing at ')
|
||||
expect(output).toContain('dsh-test: never-dispose ready')
|
||||
expect(output).toContain('dsh-test: never-dispose started')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"path": "../../vendor/include"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
"path": "../../packages/boot/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/bundle/base"
|
||||
|
||||
Reference in New Issue
Block a user