refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
6
packages/scaffold/README.i18n.yaml
Normal file
6
packages/scaffold/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/README.md
|
||||
README.md: b166a351a53afe075ecb794fe2bf6f0702a435ae
|
||||
README.zh.md: 6e2a65ea554487f48563c16e1defa37ef1a5e043
|
||||
17
packages/scaffold/README.md
Normal file
17
packages/scaffold/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# scaffold/ — create, launch, and drive projects from outside
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
This group contains developer tooling for Harness projects and the client stack for driving a Harness runtime from another process. Folders are role-named without the legacy `sdk-` prefix; npm names converge on `dsh-sdk-*` through the FIXME-tracked renames in the [regrouping Agent Note](../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md).
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`helper/`](helper/README.md) | Provides the shared project-editing domain |
|
||||
| [`scripts/`](scripts/README.md) | Provides the `dsh-sdk` project commands |
|
||||
| [`create-sdk/`](create-sdk/README.md) | Creates new SDK projects |
|
||||
| [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol |
|
||||
| [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API |
|
||||
| [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC |
|
||||
| [`telemetry/`](telemetry/README.md) | Provides launcher telemetry, consent, and redaction primitives |
|
||||
|
||||
`@deepseek-ai/create-sdk` follows npm's scoped initializer naming convention; the other packages follow the repository's `@deepseek-ai/dsh-*` convention. See the [developer-project workflow](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md), [project-editing architecture](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md), and [TypeScript SDK design](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md).
|
||||
17
packages/scaffold/README.zh.md
Normal file
17
packages/scaffold/README.zh.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# scaffold/:从外部创建、启动、驱动项目
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
本组包含 Harness 项目的开发者工具,以及从另一进程驱动 Harness 运行时的客户端栈。目录按角色命名,不带遗留的 `sdk-` 前缀;npm 名则经由[重新分组 Agent Note(agent 决策记录)](../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md)中 FIXME 跟踪的改名收敛为 `dsh-sdk-*`。
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| [`helper/`](helper/README.md) | 提供共享的项目编辑领域 |
|
||||
| [`scripts/`](scripts/README.md) | 提供 `dsh-sdk` 项目命令 |
|
||||
| [`create-sdk/`](create-sdk/README.md) | 创建新的 SDK 项目 |
|
||||
| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 |
|
||||
| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
|
||||
| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 |
|
||||
| [`telemetry/`](telemetry/README.md) | 提供启动器 telemetry、同意与脱敏原语 |
|
||||
|
||||
`@deepseek-ai/create-sdk` 遵循 npm 的 scoped initializer 命名约定;其余包遵循仓库的 `@deepseek-ai/dsh-*` 约定。参见[开发者项目工作流](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)、[项目编辑架构](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)与 [TypeScript SDK 设计](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)。
|
||||
6
packages/scaffold/client/README.i18n.yaml
Normal file
6
packages/scaffold/client/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/client/README.md
|
||||
README.md: f27ec256330254156c45b136c21308529ea99e3d
|
||||
README.zh.md: bd23762500a4da469839da8ddb50455e3419b553
|
||||
49
packages/scaffold/client/README.md
Normal file
49
packages/scaffold/client/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-sdk-client
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level owned-run API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
|
||||
|
||||
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — including the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend and automation — that know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
|
||||
|
||||
## DeepSeekHarness
|
||||
|
||||
```ts
|
||||
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
|
||||
await using harness = new DeepSeekHarness({
|
||||
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
maxTokens: 49_152,
|
||||
})
|
||||
const result = await harness.run('say hi')
|
||||
console.log(result.finalResponse)
|
||||
```
|
||||
|
||||
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
|
||||
|
||||
`run(input, { sessionId?, onNotification? })` owns one activity interval: it queues the prompt, waits until its `MessageId` appears in a durable `agent/inbox/spliced` receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`. `finalResponse` is the last committed root-session assistant text in that interval, not a response causally assigned to the prompt; steering, injected context, and other queued work may contribute before idle. `events` contains root-session events, while `notifications` also contains descendants discovered from `subagent.started`, all in wire order. The result carries no prompt-level status or turn reason. Transport loss, timeout, and protocol violations reject; model outcomes remain observable in the event stream without being attributed to one input.
|
||||
|
||||
## HarnessClient
|
||||
|
||||
The protocol client under the owned-run API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it; it never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
|
||||
|
||||
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.
|
||||
|
||||
`HarnessClientOptions.env` replaces the child environment entirely when given (`undefined` inherits the parent's); callers own credential policy — `scrubbedParentEnv` from `dsh-subprocess` is the shared scrub base for isolation-minded launches.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this is a client-process library; the model runs in the spawned runtime, whose experience is owned by the plugins its `cordis.yml` composes.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists.
|
||||
- **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../sdk-protocol/README.md)).
|
||||
- **No per-prompt result or cancel** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime.
|
||||
- **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows.
|
||||
49
packages/scaffold/client/README.zh.md
Normal file
49
packages/scaffold/client/README.zh.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# @deepseek-ai/dsh-sdk-client
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
|
||||
|
||||
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方,包括 [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端和自动化;它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
|
||||
|
||||
## DeepSeekHarness
|
||||
|
||||
```ts
|
||||
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
|
||||
|
||||
await using harness = new DeepSeekHarness({
|
||||
launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-v4-flash',
|
||||
maxTokens: 49_152,
|
||||
})
|
||||
const result = await harness.run('say hi')
|
||||
console.log(result.finalResponse)
|
||||
```
|
||||
|
||||
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
|
||||
|
||||
`run(input, { sessionId?, onNotification? })` 拥有一个活动区间:它将提示词排入队列,等待其 `MessageId` 出现在持久的 `agent/inbox/spliced` 回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`。`finalResponse` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的响应;steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`events` 包含根会话事件,`notifications` 还包含通过 `subagent.started` 发现的后代,均按协议传输顺序排列。结果不携带提示词级状态或轮次原因。传输丢失、超时和协议违例会导致 Promise 被拒绝;模型结果仍可在事件流中观察,但不会归属于某一输入。
|
||||
|
||||
## HarnessClient
|
||||
|
||||
自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
|
||||
|
||||
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
|
||||
|
||||
`HarnessClientOptions.env` 给定时整体替换子进程环境(`undefined` 原样继承父进程环境);凭据策略归调用方——`dsh-subprocess` 的 `scrubbedParentEnv` 是面向隔离启动的共享擦除基底。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为这是一个客户端进程库;模型运行在 spawn 出的运行时中,其体验由该运行时的 `cordis.yml` 所组合的插件决定。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。
|
||||
- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../sdk-protocol/README.md))。
|
||||
- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。
|
||||
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。
|
||||
40
packages/scaffold/client/package.json
Normal file
40
packages/scaffold/client/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sdk-client",
|
||||
"description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
246
packages/scaffold/client/src/api.ts
Normal file
246
packages/scaffold/client/src/api.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one
|
||||
* runtime subprocess across many sessions; `HarnessSession.run` sends a
|
||||
* prompt and settles when the whole agent next becomes idle.
|
||||
* Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/api
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { resolve } from 'node:path'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { HarnessClient, isRecord, SdkProtocolError } from './client.ts'
|
||||
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, RunResult } from './types.ts'
|
||||
|
||||
/**
|
||||
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
|
||||
* subprocess. The subprocess starts lazily on first use and stays owned by
|
||||
* this instance until {@link close}; always close (or `await using`) so the
|
||||
* child is reaped.
|
||||
*/
|
||||
export class DeepSeekHarness implements AsyncDisposable {
|
||||
private clientInstance: HarnessClient
|
||||
private readonly launch: HarnessClientOptions
|
||||
private readonly cwd: string
|
||||
private readonly provider: string
|
||||
private readonly model: string
|
||||
private readonly maxTokens: number | undefined
|
||||
private initialized: Promise<void> | undefined
|
||||
private closed = false
|
||||
|
||||
/** @param options - runtime launch spec plus the session route (cwd/provider/model). */
|
||||
constructor(options: DeepSeekHarnessOptions) {
|
||||
this.launch = options.launch
|
||||
this.clientInstance = new HarnessClient(options.launch)
|
||||
// Absolute before the handshake: the child spawns relative to THIS
|
||||
// process's cwd, but the wire cwd is resolved again inside the child — a
|
||||
// relative value would double-resolve (e.g. `worker` → `worker/worker`).
|
||||
this.cwd = resolve(options.cwd ?? options.launch.cwd ?? process.cwd())
|
||||
this.provider = options.provider ?? 'deepseek-official'
|
||||
this.model = options.model ?? 'deepseek-v4-flash'
|
||||
this.maxTokens = options.maxTokens
|
||||
}
|
||||
|
||||
/**
|
||||
* The underlying JSON-RPC client (exposed for low-level access). A failed
|
||||
* handshake reaps its runtime and swaps in a fresh instance, so do not
|
||||
* cache this across a failed {@link start}.
|
||||
* @returns the client currently owning the runtime subprocess.
|
||||
*/
|
||||
get client(): HarnessClient {
|
||||
return this.clientInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the subprocess and perform the `initialize` handshake once. On
|
||||
* failure the runtime is reaped and a fresh client replaces it
|
||||
* (`HarnessClient.close` is permanent), so a later call retries with a new
|
||||
* subprocess — unless {@link close} already ended this harness.
|
||||
* @returns settlement of the (memoized) handshake.
|
||||
*/
|
||||
start(): Promise<void> {
|
||||
this.initialized ??= (async () => {
|
||||
try {
|
||||
this.clientInstance.start()
|
||||
await this.clientInstance.initialize({
|
||||
cwd: this.cwd,
|
||||
provider: this.provider,
|
||||
model: this.model,
|
||||
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
||||
})
|
||||
} catch (error) {
|
||||
this.initialized = undefined
|
||||
await this.clientInstance.close()
|
||||
if (!this.closed) this.clientInstance = new HarnessClient(this.launch)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
return this.initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session handle (no wire traffic; the runtime creates the session
|
||||
* on its first prompt).
|
||||
* @param sessionId - explicit id to reuse; omitted mints a fresh one.
|
||||
* @returns the session handle.
|
||||
*/
|
||||
session(sessionId?: string): HarnessSession {
|
||||
return new HarnessSession(this, sessionId ?? `session-${randomUUID().replaceAll('-', '')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one prompt on a fresh (or named) session.
|
||||
* @param input - prompt text, or content blocks sent verbatim.
|
||||
* @param options - optional session id and per-notification observer.
|
||||
* @returns the owned activity interval.
|
||||
*/
|
||||
run(input: string | ContentBlock[], options?: RunOptions): Promise<RunResult> {
|
||||
return this.session(options?.sessionId).run(input, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down and reap the runtime subprocess. Idempotent and terminal —
|
||||
* a closed harness no longer retries a failed handshake.
|
||||
* @returns settlement of the complete teardown.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
this.closed = true
|
||||
return this.clientInstance.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* `await using` support: {@link close}.
|
||||
* @returns settlement of the teardown.
|
||||
*/
|
||||
[Symbol.asyncDispose](): Promise<void> {
|
||||
return this.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-run options: target session and streaming observer. */
|
||||
export interface RunOptions {
|
||||
/** Session id to run on; omitted mints a fresh session per call. */
|
||||
sessionId?: string
|
||||
/** Observer invoked with every notification for this session tree, in wire order. */
|
||||
onNotification?: (notification: HarnessNotification) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One SDK session: a stable id plus owned activity intervals.
|
||||
*/
|
||||
export class HarnessSession {
|
||||
/**
|
||||
* @param harness - the owning harness (supplies the client and handshake).
|
||||
* @param id - the wire session id this handle runs on.
|
||||
*/
|
||||
constructor(readonly harness: DeepSeekHarness, readonly id: string) {}
|
||||
|
||||
/**
|
||||
* Queue one prompt, then observe the whole session through its next idle.
|
||||
* @param input - prompt text, or content blocks sent verbatim.
|
||||
* @param options - optional per-notification observer.
|
||||
* @returns the owned activity interval; rejects on transport loss, timeout,
|
||||
* or a protocol error.
|
||||
*/
|
||||
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<RunResult> {
|
||||
await this.harness.start()
|
||||
const client = this.harness.client
|
||||
const contentBlocks = normalizeInput(input)
|
||||
const events: SessionEvent[] = []
|
||||
const notifications: HarnessNotification[] = []
|
||||
|
||||
const subscription = client.subscribeSessionTree(this.id)
|
||||
const collect = (notification: HarnessNotification): void => {
|
||||
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
|
||||
// Wire boundary: the envelope feeds the typed RunResult, so a
|
||||
// malformed runtime surfaces as a protocol error, not as type-invalid
|
||||
// data (or a TypeError out of finalResponse).
|
||||
const event = validatedSessionEvent(notification.params.event)
|
||||
notifications.push(notification)
|
||||
options?.onNotification?.(notification)
|
||||
events.push(event)
|
||||
return
|
||||
}
|
||||
notifications.push(notification)
|
||||
options?.onNotification?.(notification)
|
||||
}
|
||||
try {
|
||||
const messageId = await client.prompt(this.id, contentBlocks)
|
||||
let received = false
|
||||
while (true) {
|
||||
const notification = await subscription.next()
|
||||
if (!received) {
|
||||
if (notification.method !== 'session.event'
|
||||
|| notification.params.sessionId !== this.id
|
||||
|| !isInboxReceipt(notification.params.event, messageId)) continue
|
||||
received = true
|
||||
}
|
||||
collect(notification)
|
||||
if (notification.method === 'session.status'
|
||||
&& notification.params.sessionId === this.id
|
||||
&& notification.params.status === 'idle') break
|
||||
}
|
||||
} finally {
|
||||
subscription.close()
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: this.id,
|
||||
finalResponse: finalResponse(events),
|
||||
events,
|
||||
notifications,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize run input: a string becomes one text block; blocks pass verbatim.
|
||||
* @param input - prompt text or content blocks.
|
||||
* @returns the content blocks to send.
|
||||
*/
|
||||
export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] {
|
||||
return typeof input === 'string' ? [{ type: 'text', text: input }] : input
|
||||
}
|
||||
|
||||
/** Validate a wire `session.event` envelope to the shape the typed result exposes. */
|
||||
function validatedSessionEvent(value: unknown): SessionEvent {
|
||||
if (!isRecord(value) || typeof value.type !== 'string') {
|
||||
throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`)
|
||||
}
|
||||
// The one variant this module reads into (finalResponse) must carry
|
||||
// kind-tagged content blocks; other variants pass through under their
|
||||
// envelope shape.
|
||||
if (value.type === 'assistant/message') {
|
||||
const message = isRecord(value.data) ? value.data.message : undefined
|
||||
const content = isRecord(message) ? message.content : undefined
|
||||
if (!Array.isArray(content) || !content.every(block => isRecord(block) && typeof block.type === 'string')) {
|
||||
throw new SdkProtocolError(`assistant/message event carried malformed content: ${JSON.stringify(value)}`)
|
||||
}
|
||||
}
|
||||
return value as unknown as SessionEvent
|
||||
}
|
||||
|
||||
/** Whether a raw session event is the durable enqueue receipt for `messageId`. */
|
||||
function isInboxReceipt(value: unknown, messageId: string): boolean {
|
||||
if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false
|
||||
const inserted = value.data.inserted
|
||||
return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the concatenated text of the last assistant message.
|
||||
* @param events - the activity interval's `session.event` payloads in wire order.
|
||||
* @returns the final response text, or `''` when no assistant message exists.
|
||||
*/
|
||||
export function finalResponse(events: SessionEvent[]): string {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index]
|
||||
if (event?.type !== 'assistant/message') continue
|
||||
return event.data.message.content
|
||||
.filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
473
packages/scaffold/client/src/client.ts
Normal file
473
packages/scaffold/client/src/client.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Low-level JSON-RPC client for a DeepSeek Harness SDK runtime subprocess.
|
||||
* {@link HarnessClient} owns the child process: it spawns the runtime, speaks
|
||||
* the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans
|
||||
* server notifications out to subscriptions, and tears the child down to
|
||||
* quiescence through a private EOF → SIGTERM → SIGKILL ladder. The design
|
||||
* twin is the Python SDK's `HarnessClient` (`python/sdk`); both drive the
|
||||
* same runtime protocol. This client runs OUTSIDE any harness context, so it
|
||||
* spawns directly rather than through the `dsh-subprocess` service — the
|
||||
* seam's documented exception for SDK-managed transports.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/client
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import {
|
||||
JsonRpcLineTransport,
|
||||
JsonRpcResponseError,
|
||||
type InitializeParams,
|
||||
type InitializeResult,
|
||||
type SessionPromptParams,
|
||||
} from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { disposeRuntimeProcess } from './dispose.ts'
|
||||
import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts'
|
||||
|
||||
/** Retained stderr lines used to diagnose an unexpected runtime death. */
|
||||
const STDERR_TAIL_LIMIT = 400
|
||||
|
||||
/** Grace for the runtime's stdio streams to settle after its exit edge. */
|
||||
const STREAM_SETTLE_MS = 100
|
||||
|
||||
/**
|
||||
* The runtime subprocess is gone or unusable: it exited, its stdio closed, or
|
||||
* it was never launchable. The message carries the exit code and a stderr
|
||||
* tail when available.
|
||||
*/
|
||||
export class TransportClosedError extends Error {
|
||||
/** @param message - the failure description, including any stderr tail. */
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TransportClosedError'
|
||||
}
|
||||
}
|
||||
|
||||
/** A request exceeded {@link HarnessClientOptions.requestTimeoutMs}. */
|
||||
export class RequestTimeoutError extends Error {
|
||||
/** @param message - which method timed out. */
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RequestTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime answered outside its documented protocol (for example a
|
||||
* `session/prompt` response without `accepted: true`).
|
||||
*/
|
||||
export class SdkProtocolError extends Error {
|
||||
/** @param message - the protocol violation description. */
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'SdkProtocolError'
|
||||
}
|
||||
}
|
||||
|
||||
interface SubscriptionState {
|
||||
readonly queue: HarnessNotification[]
|
||||
readonly waiters: { resolve: (item: HarnessNotification) => void; reject: (error: Error) => void }[]
|
||||
readonly filter: NotificationFilter | undefined
|
||||
failure: Error | undefined
|
||||
}
|
||||
|
||||
/** One client-side notification stream returned by {@link HarnessClient.subscribe}. */
|
||||
export interface NotificationSubscription extends AsyncIterable<HarnessNotification> {
|
||||
/**
|
||||
* Await the next matching notification.
|
||||
* @returns the notification; after the runtime died, drains what was
|
||||
* already delivered and then rejects; after {@link close}, rejects
|
||||
* immediately (the queue is dropped).
|
||||
*/
|
||||
next(): Promise<HarnessNotification>
|
||||
|
||||
/**
|
||||
* Drain one already-delivered notification without waiting.
|
||||
* @returns the next queued notification, or `undefined` when none is queued.
|
||||
*/
|
||||
tryNext(): HarnessNotification | undefined
|
||||
|
||||
/** Detach from the client; queued items drop and pending waiters reject. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/** Internal producer side of a public notification subscription. */
|
||||
class NotificationSubscriptionImpl implements NotificationSubscription {
|
||||
constructor(
|
||||
private readonly state: SubscriptionState,
|
||||
private readonly unsubscribe: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Await the next matching notification.
|
||||
* @returns the notification; after the runtime died, drains what was
|
||||
* already delivered and then rejects; after {@link close}, rejects
|
||||
* immediately (the queue is dropped).
|
||||
*/
|
||||
next(): Promise<HarnessNotification> {
|
||||
const queued = this.state.queue.shift()
|
||||
if (queued !== undefined) return Promise.resolve(queued)
|
||||
if (this.state.failure !== undefined) return Promise.reject(this.state.failure)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.state.waiters.push({ resolve, reject })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain one already-delivered notification without waiting.
|
||||
* @returns the next queued notification, or `undefined` when none is queued.
|
||||
*/
|
||||
tryNext(): HarnessNotification | undefined {
|
||||
return this.state.queue.shift()
|
||||
}
|
||||
|
||||
/** Detach from the client; queued items drop and pending waiters reject. */
|
||||
close(): void {
|
||||
this.unsubscribe()
|
||||
// The drop is part of this method's contract; a runtime-death fail() keeps
|
||||
// the queue so already-delivered notifications remain drainable.
|
||||
this.state.queue.length = 0
|
||||
this.fail(new TransportClosedError('notification subscription closed'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject pending and future waits (delivery stops; the first failure wins).
|
||||
* Already-queued notifications remain drainable via {@link next}/{@link tryNext}.
|
||||
* @param error - the terminal failure delivered to waiters.
|
||||
*/
|
||||
fail(error: Error): void {
|
||||
this.state.failure ??= error
|
||||
for (const waiter of this.state.waiters.splice(0)) waiter.reject(this.state.failure)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one notification to a waiter or the queue when the filter
|
||||
* matches. A throwing filter fails only THIS subscription (detached, the
|
||||
* throw becomes its terminal error) — it never disturbs sibling
|
||||
* subscriptions or the transport's read loop, mirroring the Python client.
|
||||
* @param notification - the wire notification to deliver.
|
||||
*/
|
||||
push(notification: HarnessNotification): void {
|
||||
let matches: boolean
|
||||
try {
|
||||
matches = this.state.filter === undefined || this.state.filter(notification)
|
||||
} catch (error) {
|
||||
this.unsubscribe()
|
||||
this.fail(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
if (!matches) return
|
||||
const waiter = this.state.waiters.shift()
|
||||
if (waiter !== undefined) waiter.resolve(notification)
|
||||
else this.state.queue.push(notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate notifications until the subscription or runtime closes (the
|
||||
* terminating rejection propagates).
|
||||
* @returns an async iterator over {@link next} results.
|
||||
*/
|
||||
async * [Symbol.asyncIterator](): AsyncIterator<HarnessNotification> {
|
||||
for (;;) yield await this.next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC client for the DeepSeek Harness SDK runtime over subprocess stdio.
|
||||
*
|
||||
* The subprocess starts lazily on {@link start} and is owned by this instance
|
||||
* until {@link close}, which requests protocol `shutdown` and then walks the
|
||||
* shared EOF → SIGTERM → SIGKILL dispose ladder to quiescence. There is no
|
||||
* wire-level cancel: a timed-out request stays running server-side until the
|
||||
* runtime is closed.
|
||||
*/
|
||||
export class HarnessClient {
|
||||
private child: ChildProcess | undefined
|
||||
private transport: JsonRpcLineTransport | undefined
|
||||
private readonly stderrTail: string[] = []
|
||||
private readonly subscriptions = new Map<string, NotificationSubscriptionImpl>()
|
||||
private readonly sessionParents = new Map<string, string>()
|
||||
private subscriptionSerial = 0
|
||||
private exitCode: number | null | undefined
|
||||
private spawnError: Error | undefined
|
||||
private streamsSettled: Promise<void> = Promise.resolve()
|
||||
private closeTask: Promise<void> | undefined
|
||||
|
||||
/** @param options - launch spec, complete child environment, and timeouts. */
|
||||
constructor(readonly options: HarnessClientOptions) {}
|
||||
|
||||
/**
|
||||
* Spawn the runtime subprocess and start reading frames. Idempotent while
|
||||
* the process is live; rejects reuse after {@link close}.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.closeTask !== undefined) throw new TransportClosedError('DeepSeek Harness runtime client is closed')
|
||||
if (this.child !== undefined) return
|
||||
const child = spawn(this.options.command, this.options.args ?? [], {
|
||||
cwd: this.options.cwd,
|
||||
env: this.options.env ?? process.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
this.child = child
|
||||
child.once('error', (error) => {
|
||||
this.spawnError = error
|
||||
// A spawn failure destroys the pipes without an input 'end' edge, so the
|
||||
// transport's pending requests must be failed here.
|
||||
this.transport?.close()
|
||||
this.failSubscriptions(this.closedError('DeepSeek Harness runtime failed to start'))
|
||||
})
|
||||
// Writes racing the runtime's death EPIPE on stdin; the exit edge below is
|
||||
// the real signal, so the stream-level error only needs to be non-fatal.
|
||||
// The timing of that race is not deterministically reproducible.
|
||||
/* v8 ignore next */
|
||||
child.stdin.on('error', () => {})
|
||||
let stderrBuffer = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => {
|
||||
stderrBuffer += chunk
|
||||
const newline = stderrBuffer.lastIndexOf('\n')
|
||||
if (newline >= 0) {
|
||||
this.appendStderr(stderrBuffer.slice(0, newline).split('\n'))
|
||||
stderrBuffer = stderrBuffer.slice(newline + 1)
|
||||
}
|
||||
})
|
||||
let signalStreamsSettled!: () => void
|
||||
this.streamsSettled = new Promise((resolve) => { signalStreamsSettled = resolve })
|
||||
const settled = { stderr: false, exited: false }
|
||||
const maybeSettle = (): void => {
|
||||
if (settled.stderr && settled.exited) signalStreamsSettled()
|
||||
}
|
||||
child.stderr.once('close', () => {
|
||||
if (stderrBuffer.length > 0) this.appendStderr([stderrBuffer])
|
||||
settled.stderr = true
|
||||
maybeSettle()
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
this.exitCode = code
|
||||
settled.exited = true
|
||||
maybeSettle()
|
||||
this.failSubscriptions(this.closedError('DeepSeek Harness runtime exited'))
|
||||
})
|
||||
child.once('close', () => {
|
||||
// All stdio has settled: stdout 'end' already drained every tail frame,
|
||||
// so closing now cannot drop responses — it only fails requests that
|
||||
// will never be answered.
|
||||
this.transport?.close()
|
||||
})
|
||||
const transport = new JsonRpcLineTransport(child.stdout, child.stdin)
|
||||
transport.onNotification((method, params) => { this.dispatchNotification({ method, params }) })
|
||||
transport.start()
|
||||
this.transport = transport
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the process-wide handshake.
|
||||
* @param params - workspace cwd plus the provider/model route.
|
||||
* @returns the runtime's wire identity.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
const result = await this.request('initialize', { ...params })
|
||||
if (!isRecord(result) || !isRecord(result.serverInfo)
|
||||
|| typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') {
|
||||
throw new SdkProtocolError(`initialize returned no server identity: ${JSON.stringify(result)}`)
|
||||
}
|
||||
return { serverInfo: { name: result.serverInfo.name, version: result.serverInfo.version } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one prompt and return its durable inbox identity.
|
||||
* @param sessionId - target session; an unknown id creates it.
|
||||
* @param contentBlocks - the user message, sent verbatim.
|
||||
* @returns the queued message id.
|
||||
*/
|
||||
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<string> {
|
||||
const params: SessionPromptParams = { sessionId, contentBlocks }
|
||||
const result = await this.request('session/prompt', { ...params })
|
||||
if (!isRecord(result) || typeof result.messageId !== 'string') {
|
||||
throw new SdkProtocolError(`session/prompt returned no message id: ${JSON.stringify(result)}`)
|
||||
}
|
||||
return result.messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one JSON-RPC request and await its result.
|
||||
* @param method - the wire method name.
|
||||
* @param params - the params object; omitted params send `{}`.
|
||||
* @param timeoutMs - per-call override of {@link HarnessClientOptions.requestTimeoutMs}.
|
||||
* @returns the raw result; rejects with {@link JsonRpcResponseError} on a
|
||||
* protocol error response, {@link RequestTimeoutError} on timeout, and
|
||||
* {@link TransportClosedError} when the runtime is gone.
|
||||
*/
|
||||
async request(method: string, params?: object, timeoutMs?: number): Promise<unknown> {
|
||||
this.start()
|
||||
// A dead runtime cannot answer; fail with process context instead of
|
||||
// writing into a destroyed pipe and hanging until the timeout.
|
||||
if (this.exitCode !== undefined || this.spawnError !== undefined) {
|
||||
await this.settleStreams()
|
||||
throw this.closedError('DeepSeek Harness runtime is not running')
|
||||
}
|
||||
const transport = this.transport
|
||||
/* v8 ignore next -- start() either sets the transport or throws */
|
||||
if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running')
|
||||
const timeout = timeoutMs ?? this.options.requestTimeoutMs
|
||||
try {
|
||||
if (timeout === undefined) return await transport.request(method, params ?? {})
|
||||
// The abort signal makes the timeout an abandonment: the transport drops
|
||||
// its pending entry, so repeated bounded requests against a hung method
|
||||
// retain no per-call state (the server-side work still runs to close).
|
||||
const abandon = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`))
|
||||
}, timeout)
|
||||
try {
|
||||
return await transport.request(method, params ?? {}, abandon.signal)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError) throw error
|
||||
// Transport-level failures gain process context: exit code + stderr tail.
|
||||
await this.settleStreams()
|
||||
throw this.closedError(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to server notifications.
|
||||
* @param filter - optional predicate; omitted means every notification.
|
||||
* @returns the subscription handle; close it to stop delivery. After
|
||||
* {@link close} or runtime death the handle is born failed — there is no
|
||||
* producer left, so `next()` rejects instead of waiting forever.
|
||||
*/
|
||||
subscribe(filter?: NotificationFilter): NotificationSubscription {
|
||||
const id = String(this.subscriptionSerial++)
|
||||
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
|
||||
const subscription = new NotificationSubscriptionImpl(state, () => { this.subscriptions.delete(id) })
|
||||
if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) {
|
||||
subscription.fail(this.closedError('DeepSeek Harness runtime closed'))
|
||||
return subscription
|
||||
}
|
||||
this.subscriptions.set(id, subscription)
|
||||
return subscription
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to one session and the descendants discovered from
|
||||
* `subagent.started` lineage edges (the runtime notifies for every session
|
||||
* in its context; scoping is client-side, mirroring the Python SDK).
|
||||
* @param sessionId - the root session id.
|
||||
* @returns the filtered subscription handle.
|
||||
*/
|
||||
subscribeSessionTree(sessionId: string): NotificationSubscription {
|
||||
return this.subscribe((notification) => {
|
||||
const params = notification.params
|
||||
if (notification.method === 'subagent.started' || notification.method === 'subagent.finished') {
|
||||
const parentId = params.parentSessionId
|
||||
if (typeof parentId === 'string' && this.isDescendantOf(parentId, sessionId)) return true
|
||||
return params.childSessionId === sessionId
|
||||
}
|
||||
const relatedId = params.sessionId
|
||||
return typeof relatedId === 'string' && this.isDescendantOf(relatedId, sessionId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut the runtime down and reap it: a best-effort protocol `shutdown`
|
||||
* bounded by `shutdownTimeoutMs`, then the shared stdin-EOF → SIGTERM →
|
||||
* SIGKILL ladder until the process actually exited. Idempotent.
|
||||
* @returns settlement of the complete teardown.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
this.closeTask ??= this.performClose()
|
||||
return this.closeTask
|
||||
}
|
||||
|
||||
private async performClose(): Promise<void> {
|
||||
const child = this.child
|
||||
if (child === undefined) return
|
||||
try {
|
||||
await this.request('shutdown', undefined, this.options.shutdownTimeoutMs ?? 1_000)
|
||||
} catch (error) {
|
||||
// Diagnostic only: the dispose ladder below is the authoritative teardown
|
||||
// for a runtime that cannot answer shutdown anymore.
|
||||
this.appendStderr([`shutdown request failed: ${errorMessage(error)}`])
|
||||
}
|
||||
await disposeRuntimeProcess(child, {
|
||||
disposeEofGraceMs: this.options.disposeEofGraceMs ?? 6_000,
|
||||
disposeGraceMs: this.options.disposeGraceMs ?? 3_000,
|
||||
})
|
||||
this.transport?.close()
|
||||
this.failSubscriptions(this.closedError('DeepSeek Harness runtime closed'))
|
||||
}
|
||||
|
||||
private dispatchNotification(notification: HarnessNotification): void {
|
||||
this.recordSessionRelationship(notification)
|
||||
for (const subscription of this.subscriptions.values()) subscription.push(notification)
|
||||
}
|
||||
|
||||
private recordSessionRelationship(notification: HarnessNotification): void {
|
||||
if (notification.method !== 'subagent.started') return
|
||||
const parentId = notification.params.parentSessionId
|
||||
const childId = notification.params.childSessionId
|
||||
if (typeof parentId === 'string' && parentId !== '' && typeof childId === 'string' && childId !== '' && parentId !== childId) {
|
||||
this.sessionParents.set(childId, parentId)
|
||||
}
|
||||
}
|
||||
|
||||
private isDescendantOf(sessionId: string, rootSessionId: string): boolean {
|
||||
const visited = new Set<string>()
|
||||
let current = sessionId
|
||||
while (!visited.has(current)) {
|
||||
if (current === rootSessionId) return true
|
||||
visited.add(current)
|
||||
const parent = this.sessionParents.get(current)
|
||||
if (parent === undefined) return false
|
||||
current = parent
|
||||
}
|
||||
// The parent map only ever extends chains upward, so a cycle cannot form.
|
||||
/* v8 ignore next */
|
||||
return false
|
||||
}
|
||||
|
||||
private failSubscriptions(error: Error): void {
|
||||
for (const subscription of this.subscriptions.values()) subscription.fail(error)
|
||||
}
|
||||
|
||||
private appendStderr(lines: string[]): void {
|
||||
const kept = lines.filter(line => line.length > 0)
|
||||
this.stderrTail.push(...kept)
|
||||
if (this.stderrTail.length > STDERR_TAIL_LIMIT) {
|
||||
this.stderrTail.splice(0, this.stderrTail.length - STDERR_TAIL_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
private settleStreams(): Promise<void> {
|
||||
return Promise.race([
|
||||
this.streamsSettled,
|
||||
new Promise<void>((resolve) => { setTimeout(resolve, STREAM_SETTLE_MS) }),
|
||||
])
|
||||
}
|
||||
|
||||
private closedError(reason: string): TransportClosedError {
|
||||
const parts = [reason]
|
||||
if (this.spawnError !== undefined) parts.push(`spawn error: ${this.spawnError.message}`)
|
||||
if (this.exitCode !== undefined) parts.push(`exit code: ${String(this.exitCode)}`)
|
||||
if (this.stderrTail.length > 0) parts.push(`stderr tail:\n${this.stderrTail.join('\n')}`)
|
||||
return new TransportClosedError(parts.join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is a plain JSON object (the wire-boundary shape probe).
|
||||
* @param value - the wire value to probe.
|
||||
* @returns `true` iff `value` is a non-null, non-array object.
|
||||
*/
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */
|
||||
function errorMessage(error: unknown): string {
|
||||
/* v8 ignore next -- the transport and dispose ladder reject only with Errors */
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
99
packages/scaffold/client/src/dispose.ts
Normal file
99
packages/scaffold/client/src/dispose.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Private teardown ladder for the runtime subprocess: stdin EOF (cooperative
|
||||
* quiesce), then SIGTERM, then SIGKILL, resolving only after the process has
|
||||
* actually exited. The SDK client runs OUTSIDE any harness context, so it
|
||||
* cannot ride the `dsh-subprocess` service — this module is the seam's
|
||||
* documented exception for SDK-managed transports.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/dispose
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
|
||||
/**
|
||||
* Race the child's exit against a timer. Neither outcome leaves anything
|
||||
* behind on the child: the exit listener is removed on timeout and the timer
|
||||
* is cleared on exit, so the ladder's tiers never accumulate listeners.
|
||||
*/
|
||||
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const onExit = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
}
|
||||
// `.unref()` so a pending grace timer never keeps the parent's loop alive.
|
||||
const timer = setTimeout(() => {
|
||||
child.removeListener('exit', onExit)
|
||||
resolve(false)
|
||||
}, ms).unref()
|
||||
child.once('exit', onExit)
|
||||
})
|
||||
}
|
||||
|
||||
/** Force-terminate the runtime and reject if no exit edge arrives within the grace. */
|
||||
function forceTerminateWithin(child: ChildProcess, ms: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let accepted = false
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
const settle = (complete: () => void): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
complete()
|
||||
}
|
||||
const onExit = (): void => { settle(resolve) }
|
||||
const onError = (error: Error): void => { settle(() => { reject(error) }) }
|
||||
child.once('exit', onExit)
|
||||
child.once('error', onError)
|
||||
const timer = setTimeout(() => {
|
||||
const disposition = accepted ? 'accepted' : 'refused'
|
||||
settle(() => {
|
||||
reject(new Error(`runtime process did not exit within ${ms}ms after SIGKILL was ${disposition}`))
|
||||
})
|
||||
}, ms).unref()
|
||||
try {
|
||||
accepted = child.kill('SIGKILL')
|
||||
if (child.exitCode !== null || child.signalCode !== null) settle(resolve)
|
||||
} catch (error: unknown) {
|
||||
settle(() => { reject(new Error('SIGKILL failed', { cause: error })) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the runtime down to quiescence, resolving only after exit: close stdin
|
||||
* and allow cooperative flush, then use the host's graceful and forced
|
||||
* termination semantics. POSIX sends `SIGTERM` before `SIGKILL`; Windows
|
||||
* skips directly to forced termination because Node maps both signals to
|
||||
* `TerminateProcess`.
|
||||
* @param child - the runtime child process to tear down.
|
||||
* @param graces - the EOF and termination-confirmation windows (ms).
|
||||
* @param platform - the host platform, injectable for unit coverage.
|
||||
* @throws When forced termination errors or the child does not report exit
|
||||
* within `disposeGraceMs`.
|
||||
*/
|
||||
export async function disposeRuntimeProcess(
|
||||
child: ChildProcess,
|
||||
graces: { disposeEofGraceMs: number; disposeGraceMs: number },
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Promise<void> {
|
||||
// Already gone: nothing to reap.
|
||||
if (child.exitCode !== null || child.signalCode !== null) return
|
||||
// 1. Close stdin and allow cooperative teardown and durable-state flush.
|
||||
child.stdin?.end()
|
||||
if (await exitsWithin(child, graces.disposeEofGraceMs)) return
|
||||
// 2. POSIX gets a catchable graceful signal; Windows signals all force-terminate.
|
||||
if (platform !== 'win32') {
|
||||
child.kill('SIGTERM')
|
||||
if (await exitsWithin(child, graces.disposeGraceMs)) return
|
||||
}
|
||||
// 3. Force-kill and await a bounded exit edge.
|
||||
await forceTerminateWithin(child, graces.disposeGraceMs)
|
||||
}
|
||||
29
packages/scaffold/client/src/index.ts
Normal file
29
packages/scaffold/client/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TypeScript client SDK for the DeepSeek Harness runtime: spawn the
|
||||
* `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over
|
||||
* stdio JSON-RPC. `DeepSeekHarness` is the high-level run API;
|
||||
* `HarnessClient` is the lower-level protocol client. A pure library — it
|
||||
* registers nothing on a Cordis context; the runtime process it spawns is a
|
||||
* complete harness configured by its own `cordis.yml`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client
|
||||
*/
|
||||
|
||||
export { DeepSeekHarness, HarnessSession } from './api.ts'
|
||||
export type { RunOptions } from './api.ts'
|
||||
export {
|
||||
HarnessClient,
|
||||
RequestTimeoutError,
|
||||
SdkProtocolError,
|
||||
TransportClosedError,
|
||||
} from './client.ts'
|
||||
export type { NotificationSubscription } from './client.ts'
|
||||
export { JsonRpcResponseError } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
export type {
|
||||
ContentBlock,
|
||||
DeepSeekHarnessOptions,
|
||||
HarnessClientOptions,
|
||||
HarnessNotification,
|
||||
NotificationFilter,
|
||||
RunResult,
|
||||
} from './types.ts'
|
||||
31
packages/scaffold/client/src/invariant.ts
Normal file
31
packages/scaffold/client/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`.
|
||||
* @module @deepseek-ai/dsh-sdk-client/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'sdk-client-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this client library runs outside any harness context
|
||||
* (its peer is a separate runtime process); the runtime's own packages own
|
||||
* the event-stream relations.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
74
packages/scaffold/client/src/types.ts
Normal file
74
packages/scaffold/client/src/types.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Types for the TypeScript SDK client: launch options, notification shapes,
|
||||
* and owned activity results.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-client/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** One server-to-client notification as received off the wire. */
|
||||
export interface HarnessNotification {
|
||||
/** The JSON-RPC notification method name. */
|
||||
method: string
|
||||
/** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */
|
||||
params: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Predicate deciding whether a subscription receives a notification. */
|
||||
export type NotificationFilter = (notification: HarnessNotification) => boolean
|
||||
|
||||
/** Launch and timeout options for {@link HarnessClient}. */
|
||||
export interface HarnessClientOptions {
|
||||
/** The runtime executable (the `dsh-jsonrpc-agent` bin, a packaged exe, or `node`). */
|
||||
command: string
|
||||
/** Arguments passed to {@link command}. */
|
||||
args?: string[]
|
||||
/** Working directory for the runtime process itself. */
|
||||
cwd?: string
|
||||
/**
|
||||
* The complete child environment. `undefined` inherits the parent env
|
||||
* verbatim; passing an object replaces it entirely, so callers own
|
||||
* credential policy (see `scrubbedParentEnv` in `@deepseek-ai/dsh-subprocess`
|
||||
* for the shared scrub-then-merge base).
|
||||
*/
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Per-request timeout (ms); `undefined` waits indefinitely (a turn can legitimately run long). */
|
||||
requestTimeoutMs?: number
|
||||
/** Bound (ms) on the protocol `shutdown` exchange inside `close()` (default 1000). */
|
||||
shutdownTimeoutMs?: number
|
||||
/** Grace (ms) for the runtime's stdin-EOF quiesce during `close()` (default 6000). */
|
||||
disposeEofGraceMs?: number
|
||||
/** Termination confirmation window (ms) after SIGTERM/SIGKILL during `close()` (default 3000). */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Options for the high-level {@link DeepSeekHarness} wrapper. */
|
||||
export interface DeepSeekHarnessOptions {
|
||||
/** Launch spec for the runtime subprocess (command, args, cwd, env, timeouts). */
|
||||
launch: HarnessClientOptions
|
||||
/** Workspace cwd recorded on every SDK-created session (default: the launch cwd, else `process.cwd()`). */
|
||||
cwd?: string
|
||||
/** Provider route for SDK-created agents (default `deepseek-official`). */
|
||||
provider?: string
|
||||
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
|
||||
model?: string
|
||||
/** Maximum output tokens for each conversation-model request. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/** One owned session activity interval, from enqueue receipt through idle. */
|
||||
export interface RunResult {
|
||||
/** The session the activity ran on. */
|
||||
sessionId: string
|
||||
/** Concatenated text of the interval's last assistant message (empty when none). */
|
||||
finalResponse: string
|
||||
/** Every `session.event` payload for the root session, in wire order. */
|
||||
events: SessionEvent[]
|
||||
/** Every notification for the root session and discovered descendants, in wire order. */
|
||||
notifications: HarnessNotification[]
|
||||
}
|
||||
|
||||
/** Re-exported content-block alias so SDK callers need no extra import. */
|
||||
export type { ContentBlock }
|
||||
231
packages/scaffold/client/tests/dispose.spec.ts
Normal file
231
packages/scaffold/client/tests/dispose.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Deterministic ladder coverage against a scriptable fake child: each
|
||||
* escalation tier's timing is driven exactly (the client suite exercises the
|
||||
* same ladder against real subprocesses end to end).
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { disposeRuntimeProcess } from '../src/dispose.ts'
|
||||
|
||||
/** What fells a scripted {@link FakeChild}. */
|
||||
type LethalTrigger = 'eof' | NodeJS.Signals
|
||||
|
||||
/** Per-scenario script for a {@link FakeChild}. */
|
||||
interface FakeChildScript {
|
||||
/**
|
||||
* The one trigger that makes the child exit (SIGKILL always does,
|
||||
* uncatchable, like a real process). Omitted: only SIGKILL fells it.
|
||||
*/
|
||||
diesOn?: LethalTrigger
|
||||
/** Delay (ms) between the lethal trigger and the exit event. */
|
||||
delayMs?: number
|
||||
/** Complete the scripted exit inside the triggering call. */
|
||||
synchronousExit?: boolean
|
||||
/** `false` models a child spawned without a stdin pipe. */
|
||||
stdin?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A scriptable stand-in for a ChildProcess carrying exactly the surface the
|
||||
* ladder reads: `exitCode`/`signalCode`, `stdin.end()`, `kill()`, and the
|
||||
* `exit` event.
|
||||
*/
|
||||
class FakeChild extends EventEmitter {
|
||||
exitCode: number | null = null
|
||||
signalCode: NodeJS.Signals | null = null
|
||||
readonly kills: NodeJS.Signals[] = []
|
||||
stdinEnded = false
|
||||
readonly stdin: { end: () => void } | null
|
||||
|
||||
constructor(private readonly script: FakeChildScript = {}) {
|
||||
super()
|
||||
this.stdin = script.stdin === false
|
||||
? null
|
||||
: { end: () => { this.stdinEnded = true; this.maybeDie('eof') } }
|
||||
}
|
||||
|
||||
kill(signal: NodeJS.Signals): boolean {
|
||||
this.kills.push(signal)
|
||||
this.maybeDie(signal)
|
||||
return true
|
||||
}
|
||||
|
||||
private maybeDie(trigger: LethalTrigger): void {
|
||||
// SIGKILL is uncatchable — it always fells the child; any other trigger
|
||||
// only when the scenario scripts it as the lethal one.
|
||||
if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return
|
||||
const exit = (): void => {
|
||||
if (trigger === 'eof') this.exitCode = 0
|
||||
else this.signalCode = trigger
|
||||
this.emit('exit', this.exitCode, this.signalCode)
|
||||
}
|
||||
if (this.script.synchronousExit === true) exit()
|
||||
else setTimeout(exit, this.script.delayMs ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** The ladder takes a real ChildProcess; the fake carries the read surface. */
|
||||
function asChild(fake: FakeChild): ChildProcess {
|
||||
return fake as unknown as ChildProcess
|
||||
}
|
||||
|
||||
describe('disposeRuntimeProcess', () => {
|
||||
it('returns immediately for an already-exited child (no EOF, no signals)', async () => {
|
||||
const fake = new FakeChild()
|
||||
fake.exitCode = 0
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
|
||||
expect(fake.stdinEnded).toBe(false)
|
||||
expect(fake.kills).toEqual([])
|
||||
})
|
||||
|
||||
it('returns immediately for a child already dead by signal', async () => {
|
||||
const fake = new FakeChild()
|
||||
fake.signalCode = 'SIGKILL'
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
|
||||
expect(fake.stdinEnded).toBe(false)
|
||||
expect(fake.kills).toEqual([])
|
||||
})
|
||||
|
||||
it('tier 1: a cooperative child quiesces on stdin EOF — no signal is ever sent', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'eof', delayMs: 5 })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
|
||||
expect(fake.stdinEnded).toBe(true)
|
||||
expect(fake.kills).toEqual([])
|
||||
expect(fake.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('recognizes a child that exits synchronously on stdin EOF', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 })
|
||||
expect(fake.exitCode).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
|
||||
expect(fake.stdinEnded).toBe(true)
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
expect(fake.signalCode).toBe('SIGTERM')
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('recognizes a child that exits synchronously on SIGTERM', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
expect(fake.signalCode).toBe('SIGTERM')
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => {
|
||||
const fake = new FakeChild({ delayMs: 5 }) // only SIGKILL fells it
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
// Quiescence, not a request: at resolution the child has ACTUALLY exited
|
||||
// (the exit event landed, despite the scripted post-SIGKILL delay).
|
||||
expect(fake.signalCode).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('recognizes a child already gone when the final exit wait begins', async () => {
|
||||
const fake = new FakeChild({ synchronousExit: true })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL'])
|
||||
expect(fake.signalCode).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it.each(['exitCode', 'signalCode'] as const)('accepts a late OS %s marker before the final forced wait', async (marker) => {
|
||||
const fake = new FakeChild()
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
queueMicrotask(() => {
|
||||
if (marker === 'exitCode') fake.exitCode = 0
|
||||
else fake.signalCode = 'SIGTERM'
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 1, disposeGraceMs: 10 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('walks the ladder for a child spawned without a stdin pipe', async () => {
|
||||
const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'linux')
|
||||
expect(fake.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('skips the redundant SIGTERM tier on Windows and awaits forced exit', async () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 })
|
||||
await disposeRuntimeProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }, 'win32')
|
||||
expect(fake.kills).toEqual(['SIGKILL'])
|
||||
expect(fake.signalCode).toBe('SIGKILL')
|
||||
})
|
||||
|
||||
it('propagates a forced-termination error without waiting for the grace', async () => {
|
||||
const fake = new FakeChild()
|
||||
const failure = Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
fake.emit('error', failure)
|
||||
return false
|
||||
})
|
||||
|
||||
await expect(disposeRuntimeProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
|
||||
'win32',
|
||||
)).rejects.toBe(failure)
|
||||
expect(fake.kills).toEqual(['SIGKILL'])
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('wraps a synchronous forced-termination exception and removes its listeners', async () => {
|
||||
const fake = new FakeChild()
|
||||
const failure = new Error('invalid signal state')
|
||||
vi.spyOn(fake, 'kill').mockImplementation(() => { throw failure })
|
||||
|
||||
await expect(disposeRuntimeProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 1000 },
|
||||
'win32',
|
||||
)).rejects.toMatchObject({ message: 'SIGKILL failed', cause: failure })
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds a refused forced termination that produces no error or exit', async () => {
|
||||
const fake = new FakeChild()
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
return false
|
||||
})
|
||||
|
||||
await expect(disposeRuntimeProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
|
||||
'win32',
|
||||
)).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was refused')
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds an accepted forced termination that never reports exit', async () => {
|
||||
const fake = new FakeChild()
|
||||
vi.spyOn(fake, 'kill').mockImplementation((signal) => {
|
||||
fake.kills.push(signal)
|
||||
return true
|
||||
})
|
||||
|
||||
await expect(disposeRuntimeProcess(
|
||||
asChild(fake),
|
||||
{ disposeEofGraceMs: 1, disposeGraceMs: 10 },
|
||||
'win32',
|
||||
)).rejects.toThrow('runtime process did not exit within 10ms after SIGKILL was accepted')
|
||||
expect(fake.listenerCount('error')).toBe(0)
|
||||
expect(fake.listenerCount('exit')).toBe(0)
|
||||
})
|
||||
})
|
||||
230
packages/scaffold/client/tests/fake-runtime.ts
Normal file
230
packages/scaffold/client/tests/fake-runtime.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scripted stand-in for the DeepSeek Harness SDK runtime, driven entirely by
|
||||
* env vars — no model, no network, no harness imports. Speaks the runtime's
|
||||
* newline-delimited JSON-RPC protocol on stdio: answers `initialize`,
|
||||
* `session/prompt` (streaming scripted `session.event` notifications, then
|
||||
* `session.finished`, then the response), and `shutdown`.
|
||||
*
|
||||
* Script vocabulary (all optional):
|
||||
* - `FAKE_TEXT`: assistant text for each turn (default `hello from fake runtime`).
|
||||
* - `FAKE_STATUS`: the `session.finished` status (default `ok`).
|
||||
* - `FAKE_REASON_KIND`: the `session.finished` reason kind (default `completed`; `none` omits the reason).
|
||||
* - `FAKE_SUBAGENT`: also emit a child session (subagent.started + child event + subagent.finished).
|
||||
* - `FAKE_ECHO_CWD`: prefix the assistant text with the process cwd.
|
||||
* - `FAKE_ECHO_ENV`: comma-separated env names to echo as `name=value` lines in the assistant text.
|
||||
* - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
|
||||
* - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted).
|
||||
* - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7.
|
||||
* - `FAKE_INIT_ERROR_ONCE_FILE`: fail `initialize` (code 7) only when this
|
||||
* marker file does NOT exist yet, creating it — so the first runtime
|
||||
* process fails the handshake and a respawned one succeeds (retry probe).
|
||||
* - `FAKE_ECHO_CWD_IN_INIT`: reply `serverInfo.version` = this process's cwd
|
||||
* (wire-visible spawn-cwd probe).
|
||||
* - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as
|
||||
* the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
|
||||
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
|
||||
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
|
||||
* string (wire-validation probes).
|
||||
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
|
||||
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
|
||||
* arrives, then poll for the GO file before answering (deterministic
|
||||
* cancel-during-handshake window).
|
||||
* - `FAKE_HANG_PROMPT`: never answer `session/prompt` (for timeout/dispose tests).
|
||||
* - `FAKE_STREAM_THEN_MALFORMED`: stream a text chunk for the prompt, then
|
||||
* answer `{}` (no accepted) — same-pipe ordering makes the chunk arrive
|
||||
* before the protocol failure (partial-output retention probe).
|
||||
* - `FAKE_IGNORE_EOF` + `FAKE_SIGTERM_FILE`: keep running after stdin EOF; touch the file on SIGTERM (ladder probe).
|
||||
* - `FAKE_TRAP_SIGTERM`: with `FAKE_IGNORE_EOF`, survive SIGTERM too (SIGKILL-rung probe).
|
||||
* - `FAKE_EXIT_BEFORE_INIT`: exit 3 immediately (spawn-then-die probe).
|
||||
* - `FAKE_STDERR`: write this line to stderr at boot (diagnostics-tail probe).
|
||||
* - `FAKE_STDERR_NO_NEWLINE`: write this to stderr WITHOUT a newline (buffer-flush probe).
|
||||
* - `FAKE_RECORD_INIT`: append each `initialize` params JSON to this file (handshake probe).
|
||||
*/
|
||||
|
||||
import { appendFileSync, existsSync, writeFileSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const env = process.env
|
||||
|
||||
if (env.FAKE_STDERR !== undefined) process.stderr.write(`${env.FAKE_STDERR}\n`)
|
||||
if (env.FAKE_STDERR_NO_NEWLINE !== undefined) process.stderr.write(env.FAKE_STDERR_NO_NEWLINE)
|
||||
if (env.FAKE_EXIT_BEFORE_INIT !== undefined) process.exit(3)
|
||||
|
||||
if (env.FAKE_IGNORE_EOF !== undefined) {
|
||||
// Simulate a runtime that never quiesces from EOF so the dispose ladder
|
||||
// must escalate; record which rung fired.
|
||||
process.stdin.resume()
|
||||
process.stdin.on('end', () => { setInterval(() => {}, 1_000) })
|
||||
process.on('SIGTERM', () => {
|
||||
if (env.FAKE_SIGTERM_FILE !== undefined) writeFileSync(env.FAKE_SIGTERM_FILE, 'sigterm\n')
|
||||
if (env.FAKE_TRAP_SIGTERM === undefined) process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
function write(message: object): void {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`)
|
||||
}
|
||||
|
||||
function notify(method: string, params: object): void {
|
||||
write({ jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
function event(sessionId: string, type: string, data: object): void {
|
||||
notify('session.event', { sessionId, event: { type, seq: seq++, time: 0, data } })
|
||||
}
|
||||
|
||||
function assistantText(): string {
|
||||
const parts: string[] = []
|
||||
if (env.FAKE_ECHO_CWD !== undefined) parts.push(`cwd=${process.cwd()}`)
|
||||
for (const name of (env.FAKE_ECHO_ENV ?? '').split(',').filter(entry => entry.length > 0)) {
|
||||
parts.push(`${name}=${env[name] ?? ''}`)
|
||||
}
|
||||
parts.push(env.FAKE_TEXT ?? 'hello from fake runtime')
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function runTurn(sessionId: string): void {
|
||||
const text = assistantText()
|
||||
if (env.FAKE_MALFORMED_EVENT !== undefined) {
|
||||
notify('session.event', { sessionId, event: 42 })
|
||||
return
|
||||
}
|
||||
event(sessionId, 'turn/start', { turn: 0 })
|
||||
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
|
||||
if (env.FAKE_MALFORMED_MESSAGE !== undefined) {
|
||||
event(sessionId, 'assistant/message', {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
message: {
|
||||
id: 'fake-malformed-message',
|
||||
role: 'assistant',
|
||||
content: 'not-an-array',
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (env.FAKE_MESSAGE_WITHOUT_DATA !== undefined) {
|
||||
notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } })
|
||||
return
|
||||
}
|
||||
event(sessionId, 'assistant/message', {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
message: {
|
||||
id: `fake-assistant-${seq}`,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
},
|
||||
})
|
||||
const reasonKind = env.FAKE_REASON_KIND ?? 'completed'
|
||||
event(sessionId, 'turn/end', { turn: 0, reason: { kind: reasonKind } })
|
||||
if (env.FAKE_SUBAGENT !== undefined) {
|
||||
const childId = `${sessionId}-child`
|
||||
notify('subagent.started', { parentSessionId: sessionId, childSessionId: childId })
|
||||
event(childId, 'assistant/message', {
|
||||
turn: 0,
|
||||
step: 0,
|
||||
content: [{ type: 'text', text: 'child says hi' }],
|
||||
provenance: { provider: 'fake', model: 'fake' },
|
||||
})
|
||||
notify('subagent.finished', {
|
||||
provider: 'spawn',
|
||||
agentId: childId,
|
||||
parentSessionId: sessionId,
|
||||
childSessionId: childId,
|
||||
status: 'ok',
|
||||
stopReason: 'completed',
|
||||
lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIdOf(params: Record<string, unknown> | undefined): string {
|
||||
const value = params?.sessionId
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
const reader = createInterface({ input: process.stdin })
|
||||
reader.on('line', (line) => {
|
||||
if (line.trim().length === 0) return
|
||||
const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record<string, unknown> }
|
||||
if (frame.method === undefined || frame.id === undefined) return
|
||||
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
|
||||
switch (frame.method) {
|
||||
case 'initialize':
|
||||
if (env.FAKE_RECORD_INIT !== undefined) appendFileSync(env.FAKE_RECORD_INIT, `${JSON.stringify(frame.params)}\n`)
|
||||
if (env.FAKE_HANG_INIT !== undefined) return
|
||||
if (env.FAKE_INIT_READY !== undefined && env.FAKE_INIT_GO !== undefined) {
|
||||
writeFileSync(env.FAKE_INIT_READY, 'ready\n')
|
||||
const go = env.FAKE_INIT_GO
|
||||
const id = frame.id
|
||||
const poll = setInterval(() => {
|
||||
if (!existsSync(go)) return
|
||||
clearInterval(poll)
|
||||
write({ jsonrpc: '2.0', id, result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } })
|
||||
}, 5)
|
||||
return
|
||||
}
|
||||
if (env.FAKE_INIT_ERROR !== undefined) {
|
||||
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
|
||||
return
|
||||
}
|
||||
if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) {
|
||||
writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n')
|
||||
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } })
|
||||
return
|
||||
}
|
||||
if (env.FAKE_MALFORMED !== undefined) {
|
||||
respond({})
|
||||
return
|
||||
}
|
||||
if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) {
|
||||
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } })
|
||||
return
|
||||
}
|
||||
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
|
||||
return
|
||||
case 'session/prompt': {
|
||||
const sessionId = sessionIdOf(frame.params)
|
||||
const messageId = `fake-user-${seq}`
|
||||
event(sessionId, 'agent/inbox/spliced', {
|
||||
target: 'next-turn',
|
||||
start: 0,
|
||||
inserted: [{
|
||||
id: messageId,
|
||||
role: 'user',
|
||||
content: [],
|
||||
source: { kind: 'user' },
|
||||
}],
|
||||
})
|
||||
notify('session.status', { sessionId, status: 'running' })
|
||||
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
|
||||
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
|
||||
respond({})
|
||||
return
|
||||
}
|
||||
if (env.FAKE_HANG_PROMPT !== undefined) return
|
||||
if (env.FAKE_MALFORMED !== undefined || env.FAKE_MALFORMED_PROMPT !== undefined) {
|
||||
respond({})
|
||||
return
|
||||
}
|
||||
runTurn(sessionId)
|
||||
notify('session.status', { sessionId, status: 'idle' })
|
||||
respond({ messageId })
|
||||
return
|
||||
}
|
||||
case 'shutdown':
|
||||
respond({})
|
||||
// An EOF-ignoring fake also refuses the protocol exit, so the client's
|
||||
// dispose ladder (not this cooperative path) must reap it.
|
||||
if (env.FAKE_IGNORE_EOF === undefined) setImmediate(() => process.exit(0))
|
||||
return
|
||||
default:
|
||||
write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: `unknown method: ${frame.method}` } })
|
||||
}
|
||||
})
|
||||
522
packages/scaffold/client/tests/sdk-client.spec.ts
Normal file
522
packages/scaffold/client/tests/sdk-client.spec.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* SDK client against a real scripted runtime subprocess
|
||||
* (`tests/fake-runtime.ts`, protocol-only — the only faked boundary is the
|
||||
* model-owning runtime itself). Covers the turn loop, notification routing
|
||||
* and session-tree scoping, error surfaces, timeouts, and the dispose ladder.
|
||||
*/
|
||||
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DeepSeekHarness,
|
||||
HarnessClient,
|
||||
HarnessSession,
|
||||
JsonRpcResponseError,
|
||||
RequestTimeoutError,
|
||||
SdkProtocolError,
|
||||
TransportClosedError,
|
||||
type HarnessNotification,
|
||||
} from '../src/index.ts'
|
||||
import { finalResponse, normalizeInput } from '../src/api.ts'
|
||||
|
||||
const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
|
||||
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0)) await cleanup()
|
||||
})
|
||||
|
||||
type LaunchOverrides = Partial<ConstructorParameters<typeof HarnessClient>[0]>
|
||||
|
||||
/** Launch options running the fake runtime on the current node (type stripping). */
|
||||
function fakeLaunch(env: Record<string, string> = {}, extra: LaunchOverrides = {}) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [fakeRuntime],
|
||||
env: { ...process.env as Record<string, string>, ...env },
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function harnessWith(env: Record<string, string> = {}, extra: LaunchOverrides = {}): DeepSeekHarness {
|
||||
const harness = new DeepSeekHarness({ launch: fakeLaunch(env, extra) })
|
||||
cleanups.push(() => harness.close())
|
||||
return harness
|
||||
}
|
||||
|
||||
async function tempDir(prefix: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix))
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('DeepSeekHarness', () => {
|
||||
it('ignores notifications that precede the submitted message receipt', async () => {
|
||||
const notifications = [
|
||||
{ method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
|
||||
{
|
||||
method: 'session.event',
|
||||
params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
|
||||
},
|
||||
{
|
||||
method: 'session.event',
|
||||
params: {
|
||||
sessionId: 'owned',
|
||||
event: { type: 'agent/inbox/spliced', data: { inserted: null } },
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'session.event',
|
||||
params: {
|
||||
sessionId: 'owned',
|
||||
event: {
|
||||
type: 'agent/inbox/spliced',
|
||||
seq: 0,
|
||||
time: 0,
|
||||
data: {
|
||||
target: 'next-turn',
|
||||
start: 0,
|
||||
inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
|
||||
] as HarnessNotification[]
|
||||
let closed = false
|
||||
const harness = {
|
||||
start: () => Promise.resolve(),
|
||||
client: {
|
||||
prompt: () => Promise.resolve('accepted-message'),
|
||||
subscribeSessionTree: () => ({
|
||||
next: async () => {
|
||||
const notification = notifications.shift()
|
||||
if (notification === undefined) throw new Error('scripted notification queue exhausted')
|
||||
return notification
|
||||
},
|
||||
tryNext: () => notifications.shift(),
|
||||
close: () => { closed = true },
|
||||
async * [Symbol.asyncIterator]() {},
|
||||
}),
|
||||
},
|
||||
} as unknown as DeepSeekHarness
|
||||
|
||||
const result = await new HarnessSession(harness, 'owned').run('go')
|
||||
|
||||
expect(result.notifications.map(notification => notification.method))
|
||||
.toEqual(['session.event', 'session.status'])
|
||||
expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
|
||||
expect(closed).toBe(true)
|
||||
})
|
||||
|
||||
it('runs a turn end to end and reuses the runtime across sessions', async () => {
|
||||
const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
|
||||
const first = await harness.run('say hi')
|
||||
expect(first.finalResponse).toBe('turn answer')
|
||||
expect(first.events.map(event => event.type)).toEqual([
|
||||
'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
|
||||
])
|
||||
|
||||
// Same subprocess, second session: ids differ, protocol state is reusable.
|
||||
const second = await harness.run([{ type: 'text', text: 'again' }])
|
||||
expect(second.sessionId).not.toBe(first.sessionId)
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('keeps events root-scoped while streaming notifications for the session tree', async () => {
|
||||
const harness = harnessWith({ FAKE_SUBAGENT: '1' })
|
||||
const seen: HarnessNotification[] = []
|
||||
const result = await harness.run('delegate', {
|
||||
sessionId: 'parent-1',
|
||||
onNotification: (n) => { seen.push(n) },
|
||||
})
|
||||
|
||||
// The child session's events arrive through subagent.started lineage.
|
||||
expect(seen.map(n => n.method)).toContain('subagent.started')
|
||||
expect(seen.map(n => n.method)).toContain('subagent.finished')
|
||||
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
|
||||
expect(childEvents.length).toBeGreaterThan(0)
|
||||
// RunResult.events is the root session's typed stream; descendants retain
|
||||
// their session ids in the raw notification stream above.
|
||||
expect(result.events.every(event => event.type !== 'assistant/message'
|
||||
|| event.data.message.content[0]?.type !== 'text'
|
||||
|| event.data.message.content[0].text !== 'child says hi')).toBe(true)
|
||||
await harness.close()
|
||||
})
|
||||
|
||||
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
|
||||
const dir = await tempDir('sdk-client-init-')
|
||||
const recordFile = join(dir, 'init.jsonl')
|
||||
const harness = new DeepSeekHarness({
|
||||
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile }),
|
||||
cwd: dir,
|
||||
provider: 'custom-provider',
|
||||
model: 'custom-model',
|
||||
maxTokens: 4096,
|
||||
})
|
||||
cleanups.push(() => harness.close())
|
||||
await harness.run('one')
|
||||
await harness.run('two')
|
||||
await harness.close()
|
||||
const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object)
|
||||
expect(records).toEqual([{
|
||||
cwd: dir,
|
||||
provider: 'custom-provider',
|
||||
model: 'custom-model',
|
||||
maxTokens: 4096,
|
||||
}])
|
||||
})
|
||||
|
||||
it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {
|
||||
// vitest workers forbid chdir, so derive a RELATIVE path from the real
|
||||
// process cwd to a temp worker dir; resolution is lexical either way.
|
||||
const dir = await tempDir('sdk-client-relcwd-')
|
||||
const recordFile = join(dir, 'init.jsonl')
|
||||
const inner = join(dir, 'worker')
|
||||
await mkdir(inner)
|
||||
const relativeCwd = relative(process.cwd(), inner)
|
||||
expect(isAbsolute(relativeCwd)).toBe(false)
|
||||
const harness = new DeepSeekHarness({
|
||||
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }),
|
||||
})
|
||||
cleanups.push(() => harness.close())
|
||||
await harness.start()
|
||||
const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' })
|
||||
await harness.close()
|
||||
// The child spawned under the temp worker dir (its physical cwd)...
|
||||
expect(identity.serverInfo.version).toBe(await realpath(inner))
|
||||
// ...and the handshake wire cwd went out ABSOLUTE, so the child cannot
|
||||
// re-resolve a relative string into dir/worker/worker.
|
||||
const records = (await readFile(recordFile, 'utf8')).trim().split('\n')
|
||||
.map(line => (JSON.parse(line) as { cwd: string }).cwd)
|
||||
expect(records).toEqual([resolvePath(relativeCwd), inner])
|
||||
})
|
||||
|
||||
it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
|
||||
const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
|
||||
const failure = await harness.run('boom').then(
|
||||
() => { throw new Error('run unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(failure).toBeInstanceOf(JsonRpcResponseError)
|
||||
expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } })
|
||||
// The failed handshake reset lets a later start retry instead of wedging.
|
||||
await expect(harness.run('later')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('retries a failed handshake with a fresh runtime process', async () => {
|
||||
const dir = await tempDir('sdk-client-retry-')
|
||||
const marker = join(dir, 'first-boot-failed')
|
||||
const harness = harnessWith({ FAKE_INIT_ERROR_ONCE_FILE: marker, FAKE_TEXT: 'second boot answer' })
|
||||
const firstClient = harness.client
|
||||
// First start: the scripted runtime fails the handshake and is reaped.
|
||||
await expect(harness.start()).rejects.toThrow('scripted first-boot failure')
|
||||
// Retry spawns a NEW subprocess through a fresh client (close is permanent).
|
||||
const result = await harness.run('again')
|
||||
expect(harness.client).not.toBe(firstClient)
|
||||
expect(result.finalResponse).toBe('second boot answer')
|
||||
await harness.close()
|
||||
// close() is terminal: a handshake failure after it must not respawn.
|
||||
await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError)
|
||||
})
|
||||
|
||||
it('rejects a malformed initialize result as a protocol error', async () => {
|
||||
const harness = harnessWith({ FAKE_MALFORMED: '1' })
|
||||
await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
|
||||
})
|
||||
|
||||
it('supports await using disposal', async () => {
|
||||
let captured: DeepSeekHarness
|
||||
{
|
||||
await using harness = new DeepSeekHarness({ launch: fakeLaunch() })
|
||||
captured = harness
|
||||
const result = await harness.run('scoped')
|
||||
expect(result.finalResponse).toBe('hello from fake runtime')
|
||||
}
|
||||
// After scope exit the runtime is closed: reuse fails loudly.
|
||||
await expect(captured.run('after')).rejects.toThrow(TransportClosedError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HarnessClient', () => {
|
||||
it('times out a hung request at the per-call bound', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('hi') }, 200))
|
||||
.rejects.toThrow(RequestTimeoutError)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('a timed-out request leaves no pending transport state', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
for (let round = 0; round < 3; round++) {
|
||||
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50))
|
||||
.rejects.toThrow(RequestTimeoutError)
|
||||
}
|
||||
// Abandonment removed each pending entry at its timeout; a hung method
|
||||
// retains nothing per call. (Private map read is the observable here —
|
||||
// no wire surface reports transport bookkeeping.)
|
||||
const transport = (client as unknown as { transport: { pending: Map<string, unknown> } }).transport
|
||||
expect(transport.pending.size).toBe(0)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('applies the client-wide request timeout when no per-call bound is given', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
|
||||
cleanups.push(() => client.close())
|
||||
// The bound applies from send, so it holds regardless of runtime boot time.
|
||||
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('rejects a malformed prompt acceptance as a protocol error', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_MALFORMED: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError)
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('fails pending requests with exit code and stderr tail when the runtime dies', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' }))
|
||||
cleanups.push(() => client.close())
|
||||
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
|
||||
() => { throw new Error('initialize unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(failure).toBeInstanceOf(TransportClosedError)
|
||||
expect(String(failure)).toContain('exit code: 3')
|
||||
expect(String(failure)).toContain('fatal: scripted death')
|
||||
// Requests after death fail immediately with the same context.
|
||||
await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3')
|
||||
})
|
||||
|
||||
it('flushes an unterminated stderr line into the tail at close', async () => {
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
|
||||
() => { throw new Error('initialize unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(String(failure)).toContain('no trailing newline')
|
||||
})
|
||||
|
||||
it('fails fast when the command does not exist', async () => {
|
||||
const client = new HarnessClient({ command: join(tmpdir(), 'dsh-no-such-runtime-bin') })
|
||||
cleanups.push(() => client.close())
|
||||
await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError)
|
||||
})
|
||||
|
||||
it('close() is idempotent, reaps the child, and fails later use', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
await Promise.all([client.close(), client.close()])
|
||||
expect(() => { client.start() }).toThrow(TransportClosedError)
|
||||
await expect(client.request('anything')).rejects.toThrow(TransportClosedError)
|
||||
// Close with no child ever spawned is a no-op.
|
||||
const untouched = new HarnessClient(fakeLaunch())
|
||||
await untouched.close()
|
||||
})
|
||||
|
||||
it('escalates through SIGTERM when the runtime ignores EOF', async () => {
|
||||
const dir = await tempDir('sdk-client-ladder-')
|
||||
const sigtermFile = join(dir, 'sigterm.txt')
|
||||
const client = new HarnessClient(fakeLaunch(
|
||||
{ FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile },
|
||||
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 },
|
||||
))
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
await client.close()
|
||||
expect((await stat(sigtermFile)).isFile()).toBe(true)
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {
|
||||
const client = new HarnessClient(fakeLaunch(
|
||||
{ FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' },
|
||||
{ shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 },
|
||||
))
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
// Resolves (does not hang or reject): the SIGKILL rung reaped the child.
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
cleanups.push(() => client.close())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
|
||||
const all = client.subscribe()
|
||||
const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
|
||||
const firstPending = all.next()
|
||||
await client.prompt('sub-test', normalizeInput('go'))
|
||||
|
||||
const first = await firstPending
|
||||
expect(first.method).toBe('session.event')
|
||||
const idle = await idleOnly.next()
|
||||
expect(idle.method).toBe('session.status')
|
||||
expect(idleOnly.tryNext()).toBeUndefined()
|
||||
|
||||
// A bare unbounded request with omitted params sends `{}` on the wire.
|
||||
const identity = await client.request('initialize') as { serverInfo: { name: string } }
|
||||
expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
|
||||
|
||||
// Async iteration consumes queued items and then parks.
|
||||
const collected: string[] = []
|
||||
for await (const notification of all) {
|
||||
collected.push(notification.method)
|
||||
if (notification.method === 'session.status' && notification.params.status === 'idle') break
|
||||
}
|
||||
expect(collected.at(-1)).toBe('session.status')
|
||||
|
||||
all.close()
|
||||
idleOnly.close()
|
||||
await expect(all.next()).rejects.toThrow('notification subscription closed')
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('contains a throwing filter to its own subscription', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
cleanups.push(() => client.close())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
|
||||
const broken = client.subscribe(() => { throw new Error('filter exploded') })
|
||||
// A non-Error throw is normalized rather than crashing dispatch.
|
||||
const brokenNonError = client.subscribe(() => { throw 'string boom' })
|
||||
const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
|
||||
await client.prompt('filter-contain', normalizeInput('go'))
|
||||
|
||||
// The sibling subscription and the read loop are undisturbed.
|
||||
expect((await healthy.next()).method).toBe('session.status')
|
||||
// Each broken subscription failed with ITS OWN error and detached.
|
||||
await expect(broken.next()).rejects.toThrow('filter exploded')
|
||||
await expect(brokenNonError.next()).rejects.toThrow('string boom')
|
||||
healthy.close()
|
||||
await client.close()
|
||||
})
|
||||
|
||||
it('close() drops queued notifications; runtime death keeps them drainable', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
const closed = client.subscribe()
|
||||
const drainable = client.subscribe()
|
||||
await client.prompt('queue-drop', normalizeInput('go'))
|
||||
expect(closed.tryNext()).toBeDefined()
|
||||
closed.close()
|
||||
// Manual close drops the rest of the queue outright.
|
||||
expect(closed.tryNext()).toBeUndefined()
|
||||
await expect(closed.next()).rejects.toThrow('notification subscription closed')
|
||||
// Runtime teardown, by contrast, only stops FUTURE delivery: what was
|
||||
// already delivered before close() stays drainable.
|
||||
await client.close()
|
||||
expect(drainable.tryNext()).toBeDefined()
|
||||
})
|
||||
|
||||
it('subscriptions created after termination are born failed', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
await client.close()
|
||||
// No producer can ever feed this subscription; next() must not park forever.
|
||||
await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError)
|
||||
|
||||
const dead = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' }))
|
||||
cleanups.push(() => dead.close())
|
||||
await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {})
|
||||
await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError)
|
||||
})
|
||||
|
||||
it('closes subscriptions with the runtime and rejects parked waiters', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
const subscription = client.subscribe()
|
||||
const parked = subscription.next()
|
||||
await client.close()
|
||||
await expect(parked).rejects.toThrow(TransportClosedError)
|
||||
})
|
||||
|
||||
it('scopes the session tree across multi-hop lineage and ignores foreign sessions', async () => {
|
||||
const client = new HarnessClient(fakeLaunch())
|
||||
cleanups.push(() => client.close())
|
||||
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
|
||||
|
||||
const tree = client.subscribeSessionTree('root')
|
||||
// Lineage edges arrive as subagent.started notifications.
|
||||
const inject = (method: string, params: Record<string, unknown>): void => {
|
||||
(client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params })
|
||||
}
|
||||
inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' })
|
||||
inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' })
|
||||
inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } })
|
||||
inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } })
|
||||
inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' })
|
||||
inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' })
|
||||
// Self-loop and empty edges must not corrupt the lineage map.
|
||||
inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' })
|
||||
inject('subagent.started', { parentSessionId: '', childSessionId: 'x' })
|
||||
inject('subagent.finished', { childSessionId: 'root' })
|
||||
|
||||
expect((await tree.next()).method).toBe('subagent.started')
|
||||
expect((await tree.next()).method).toBe('subagent.started')
|
||||
expect((await tree.next()).params.sessionId).toBe('grandchild')
|
||||
expect((await tree.next()).method).toBe('subagent.finished')
|
||||
// The foreign-root edge and stranger event were filtered; next is the root-child edge.
|
||||
expect((await tree.next()).params.childSessionId).toBe('root')
|
||||
tree.close()
|
||||
await client.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('wire payload validation', () => {
|
||||
it('rejects a non-object session.event envelope as a protocol error', async () => {
|
||||
const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' })
|
||||
await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError)
|
||||
})
|
||||
|
||||
it('rejects an assistant/message without a content array as a protocol error', async () => {
|
||||
const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' })
|
||||
await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError)
|
||||
})
|
||||
|
||||
it('rejects an assistant/message without a data member as a protocol error', async () => {
|
||||
const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' })
|
||||
await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('stderr tail bound', () => {
|
||||
it('keeps only the newest lines up to the limit', async () => {
|
||||
const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
|
||||
const client = new HarnessClient(fakeLaunch({ FAKE_STDERR: manyLines, FAKE_EXIT_BEFORE_INIT: '1' }))
|
||||
cleanups.push(() => client.close())
|
||||
const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
|
||||
() => { throw new Error('initialize unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const text = String(failure)
|
||||
// The tail is bounded to the newest 400 lines: the oldest are dropped.
|
||||
expect(text).toContain('line-449')
|
||||
expect(text).not.toContain('line-0\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pure helpers', () => {
|
||||
it('normalizeInput wraps strings and passes blocks through', () => {
|
||||
expect(normalizeInput('x')).toEqual([{ type: 'text', text: 'x' }])
|
||||
const blocks = [{ type: 'text' as const, text: 'y' }]
|
||||
expect(normalizeInput(blocks)).toBe(blocks)
|
||||
})
|
||||
|
||||
it('finalResponse reads the last assistant message and tolerates absence', () => {
|
||||
expect(finalResponse([])).toBe('')
|
||||
expect(finalResponse([{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } } as never])).toBe('')
|
||||
expect(finalResponse([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'first' }] } } } as never,
|
||||
{ type: 'assistant/message', seq: 1, time: 0, data: { message: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } } as never,
|
||||
])).toBe('ab')
|
||||
})
|
||||
})
|
||||
30
packages/scaffold/client/tsconfig.json
Normal file
30
packages/scaffold/client/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/scaffold/create-sdk/README.i18n.yaml
Normal file
6
packages/scaffold/create-sdk/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/create-sdk/README.md
|
||||
README.md: aa09236832a50abdcd2b158e0561db26bce19cf6
|
||||
README.zh.md: a4685e66bc3eb7924dd247a7b6b420f6bcc70990
|
||||
25
packages/scaffold/create-sdk/README.md
Normal file
25
packages/scaffold/create-sdk/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# `@deepseek-ai/create-sdk`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Interactive initializer for `npm create @deepseek-ai/sdk [directory]`. Directory/name/description have visible editable defaults. A tree picker selects features and configures finite options with Right/Left navigation; secret text follows only for selected options. Local plugin creation is one none/plugin/tool choice.
|
||||
|
||||
The supported package surface is the `create-sdk` bin. The package root exports no symbols, and workflow, bin, source, and package-manifest subpaths are not exported.
|
||||
|
||||
The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command.
|
||||
|
||||
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config <path>` / `--config-json <json>` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run.
|
||||
|
||||
The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none.
|
||||
25
packages/scaffold/create-sdk/README.zh.md
Normal file
25
packages/scaffold/create-sdk/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# `@deepseek-ai/create-sdk`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于 `npm create @deepseek-ai/sdk [directory]` 的交互式初始化器。目录/名称/描述都提供可见且可编辑的默认值。树形选择器用于选择功能,并通过 Right/Left 导航配置取值有限的选项;只有选中相应选项后才会询问密钥文本。本地插件创建提供 none/plugin/tool 三选一。
|
||||
|
||||
受支持的包接口是 `create-sdk` bin。包根不导出任何符号,也不导出 workflow、bin、source 或 package-manifest 子路径。
|
||||
|
||||
初始化器拒绝任何已经存在的目标路径,创建一个 `SdkProject` 编辑会话,验证并提交该会话,然后询问是否安装 NPM 依赖并构建。安装或构建失败时会保留生成的项目,并打印重试命令。
|
||||
|
||||
公开标志包括 `[directory]`、`--description`、`--provider`、`--base-url`、`--api-key`、`--model`、`--interface`、`--pm`、`--install`/`--no-install`,以及无头模式标志 `--config <path>`/`--config-json <json>` 和 `--json`。交互式标志会预填对应问题;无头 spec(`--config`/`--config-json`)会预先提供所有答案和功能方案,因此创建过程无需 TTY,并通过 `HeadlessPromptPort` 驱动;若缺少任何必填答案,该端口会明确失败。`--json` 会发送 NDJSON 生命周期事件(`done`/`action-required`/`error`),使 agent(智能体)能够补充其中点名的缺失输入并重新运行。
|
||||
|
||||
提供方可以选择 DeepSeek,也可以选择由 `llm-pi-ai` 支持的自定义端点。选择 DeepSeek 时只询问 API key,并使用公共端点与 `deepseek-v4-flash`;自定义端点还会询问 base URL。密钥为空时必须确认;系统会在 `.env` 中创建一个被注释掉的空变量,从而使提供方在为该变量填入值之前启动时明确失败。现有插件的默认值会被省略;必填 SDK 预设仍按所属包的 Config 保持类型约束。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过生成的项目组合及其所选运行时插件间接提供;此外,无头 `--config-json` + `--json` 接口允许 agent 端到端创建项目,并响应 `action-required` 事件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接导致 KV Cache 失效;由具名消费方负责请求前缀变更。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无头本地插件**:无头 spec 会提供项目答案和功能方案;目前还不能在 spec 中表达本地插件脚手架(交互式 none/plugin/tool 选择),默认使用 none。
|
||||
42
packages/scaffold/create-sdk/package.json
Normal file
42
packages/scaffold/create-sdk/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/create-sdk",
|
||||
"description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"create-sdk": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/bin.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-helper": "workspace:^",
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
96
packages/scaffold/create-sdk/src/args.ts
Normal file
96
packages/scaffold/create-sdk/src/args.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Commander adapter for the create-sdk command surface.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/args
|
||||
*/
|
||||
|
||||
import { Command, Option } from 'commander'
|
||||
import type { PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
|
||||
|
||||
/** Parsed create command flags before interactive resolution. */
|
||||
export interface CreateArgs {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
runInterface?: RunInterface
|
||||
packageManager?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
interface CommanderCreateOptions {
|
||||
description?: string
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
interface?: RunInterface
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
function createProgram(): Command {
|
||||
return new Command()
|
||||
.name('create-sdk')
|
||||
.description('Create a DeepSeek Harness SDK project')
|
||||
.helpOption(false)
|
||||
.showHelpAfterError(false)
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
/* v8 ignore next -- the command wrapper renders the package-owned usage template */
|
||||
writeOut: () => {},
|
||||
/* v8 ignore next -- Commander output is deliberately suppressed; errors are returned to the bin wrapper */
|
||||
writeErr: () => {},
|
||||
})
|
||||
.argument('[directory]')
|
||||
.option('-h, --help')
|
||||
.option('--description <text>')
|
||||
.addOption(new Option('--provider <name>').choices(['deepseek-official', 'custom']))
|
||||
.option('--base-url <url>')
|
||||
.option('--api-key <key>')
|
||||
.option('--model <name>')
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'embed']))
|
||||
.addOption(new Option('--pm <name>').choices(['npm', 'pnpm', 'yarn']))
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
.option('--link-workspace')
|
||||
.option('--config <path>')
|
||||
.option('--config-json <json>')
|
||||
.addOption(new Option('--json').default(undefined))
|
||||
}
|
||||
|
||||
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
|
||||
export function parseCreateArgs(argv: readonly string[]): CreateArgs {
|
||||
const program = createProgram()
|
||||
program.parse([...argv], { from: 'user' })
|
||||
const options = program.opts<CommanderCreateOptions>()
|
||||
const directory = program.processedArgs[0] as string | undefined
|
||||
return {
|
||||
...directory === undefined ? {} : { directory },
|
||||
...options.description === undefined ? {} : { description: options.description },
|
||||
...options.provider === undefined ? {} : { provider: options.provider },
|
||||
...options.baseUrl === undefined ? {} : { baseURL: options.baseUrl },
|
||||
...options.apiKey === undefined ? {} : { apiKey: options.apiKey },
|
||||
...options.model === undefined ? {} : { model: options.model },
|
||||
...options.interface === undefined ? {} : { runInterface: options.interface },
|
||||
...options.pm === undefined ? {} : { packageManager: options.pm },
|
||||
...options.install === undefined ? {} : { install: options.install },
|
||||
...options.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
...options.config === undefined ? {} : { config: options.config },
|
||||
...options.configJson === undefined ? {} : { configJson: options.configJson },
|
||||
...options.json === undefined ? {} : { json: options.json },
|
||||
help: options.help ?? false,
|
||||
}
|
||||
}
|
||||
10
packages/scaffold/create-sdk/src/bin.ts
Normal file
10
packages/scaffold/create-sdk/src/bin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Self-executing create-sdk command.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/bin
|
||||
*/
|
||||
|
||||
import { runCreateCommand } from './command.ts'
|
||||
|
||||
process.exitCode = await runCreateCommand()
|
||||
144
packages/scaffold/create-sdk/src/command.ts
Normal file
144
packages/scaffold/create-sdk/src/command.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Internal create-sdk command composition used by the package bin.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/command
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
ClackPromptPort,
|
||||
HeadlessPromptError,
|
||||
HeadlessPromptPort,
|
||||
NodeCommandRunner,
|
||||
PromptCancelledError,
|
||||
type PackageManagerVersionProbe,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { parseCreateArgs, type CreateArgs } from './args.ts'
|
||||
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts'
|
||||
import { resolveHeadless } from './headless.ts'
|
||||
import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts'
|
||||
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
|
||||
|
||||
/** Process and terminal slice used by the initializer. */
|
||||
export interface CreateCommandContext {
|
||||
cwd: string
|
||||
stdin: NodeJS.ReadStream
|
||||
stdout: NodeJS.WriteStream
|
||||
stderr: NodeJS.WriteStream
|
||||
releaseVersion?: string
|
||||
versionProbe?: PackageManagerVersionProbe
|
||||
port?: PromptPort
|
||||
setup?: (request: ResolvedCreateRequest) => Promise<void>
|
||||
}
|
||||
|
||||
/** Read this initializer package's release version in source and built layouts. */
|
||||
export async function readCreateSdkVersion(): Promise<string> {
|
||||
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown }
|
||||
/* v8 ignore next -- this package's checked-in manifest always carries its version */
|
||||
if (typeof manifest.version !== 'string') throw new Error('create-sdk package version is missing')
|
||||
return manifest.version
|
||||
}
|
||||
|
||||
/** Resolve, write, optionally install, and build one new project. */
|
||||
export async function createProject(
|
||||
argv: readonly string[],
|
||||
context: CreateCommandContext,
|
||||
): Promise<ScaffoldResult | undefined> {
|
||||
const args = parseCreateArgs(argv)
|
||||
// Under --json, stdout carries only NDJSON events: human-readable progress
|
||||
// and package-manager child output move to stderr.
|
||||
const progress = args.json === true ? context.stderr : context.stdout
|
||||
if (args.help) {
|
||||
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
|
||||
return undefined
|
||||
}
|
||||
const headless = await resolveHeadless(args)
|
||||
if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('create-sdk requires an interactive TTY, --config <file>, or --config-json <json>')
|
||||
}
|
||||
const wizard = new CreateWizard({
|
||||
args: headless ? headless.args : args,
|
||||
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
|
||||
port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)),
|
||||
cwd: context.cwd,
|
||||
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
|
||||
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
|
||||
...headless?.features ? { features: headless.features } : {},
|
||||
})
|
||||
const resolved = await wizard.run()
|
||||
const result = await scaffoldProject(resolved.directory, resolved.request)
|
||||
progress.write(CREATE_TEMPLATES.created.render({
|
||||
name: resolved.request.name,
|
||||
directory: resolved.directory,
|
||||
}))
|
||||
if (resolved.install) {
|
||||
try {
|
||||
if (context.setup) await context.setup(resolved)
|
||||
else {
|
||||
const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner()
|
||||
await resolved.request.packageManager.install(resolved.directory, runner)
|
||||
await resolved.request.packageManager.build(resolved.directory, runner)
|
||||
}
|
||||
} catch (error) {
|
||||
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
|
||||
directory: resolved.directory,
|
||||
error: String(error),
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
progress.write(CREATE_TEMPLATES.nextSteps.render({
|
||||
directory: resolved.directory,
|
||||
setupRequired: !resolved.install,
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
}))
|
||||
return result
|
||||
}
|
||||
|
||||
/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */
|
||||
function wantsJsonEvents(argv: readonly string[]): boolean {
|
||||
let parsed: CreateArgs
|
||||
try {
|
||||
parsed = parseCreateArgs(argv)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return parsed.json === true
|
||||
}
|
||||
|
||||
/** Run the create command with process defaults and convert cancellation to a clean exit. */
|
||||
export async function runCreateCommand(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
context: CreateCommandContext = {
|
||||
cwd: process.cwd(),
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
},
|
||||
): Promise<number> {
|
||||
const json = wantsJsonEvents(argv)
|
||||
const emit = (event: Record<string, unknown>): void => {
|
||||
context.stdout.write(`${JSON.stringify(event)}\n`)
|
||||
}
|
||||
try {
|
||||
await createProject(argv, context)
|
||||
if (json) emit({ type: 'done' })
|
||||
return 0
|
||||
} catch (error) {
|
||||
if (error instanceof PromptCancelledError) {
|
||||
if (json) emit({ type: 'error', reason: 'cancelled' })
|
||||
else context.stderr.write('create-sdk: cancelled\n')
|
||||
return 1
|
||||
}
|
||||
if (json && error instanceof HeadlessPromptError) {
|
||||
emit({ type: 'action-required', prompt: error.prompt })
|
||||
return 1
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (json) emit({ type: 'error', message })
|
||||
else context.stderr.write(`create-sdk: ${message}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
204
packages/scaffold/create-sdk/src/create-questions.ts
Normal file
204
packages/scaffold/create-sdk/src/create-questions.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Static create-sdk question sequence; dynamic feature/plugin loops remain
|
||||
* in the wizard orchestrator.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/create-questions
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import {
|
||||
ConfirmQuestion,
|
||||
SecretQuestion,
|
||||
SelectQuestion,
|
||||
TextQuestion,
|
||||
requireAnswer,
|
||||
type PromptPort,
|
||||
type Question,
|
||||
type RunInterface,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import type { CreateArgs } from './args.ts'
|
||||
|
||||
/** Answers that establish project identity and feature applicability. */
|
||||
export interface ProjectAnswers {
|
||||
directory: string
|
||||
name: string
|
||||
description: string
|
||||
provider: 'deepseek-official' | 'custom'
|
||||
baseURL: string
|
||||
apiKey: string
|
||||
model: string
|
||||
runInterface: RunInterface
|
||||
}
|
||||
|
||||
interface ProjectAnswerState extends Partial<ProjectAnswers> {
|
||||
readonly args: CreateArgs
|
||||
readonly cwd: string
|
||||
}
|
||||
|
||||
interface WizardStep<TState> {
|
||||
run(port: PromptPort, state: TState): Promise<void>
|
||||
}
|
||||
|
||||
function questionStep<TState, TValue>(options: {
|
||||
question: (state: TState) => Question<TValue>
|
||||
when?: (state: TState) => boolean
|
||||
prefilled?: (state: TState) => TValue | undefined
|
||||
apply: (state: TState, value: TValue) => void
|
||||
}): WizardStep<TState> {
|
||||
return {
|
||||
async run(port, state) {
|
||||
if (options.when && !options.when(state)) return
|
||||
const value = requireAnswer(await options.question(state).resolve(port, options.prefilled?.(state)))
|
||||
options.apply(state, value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one required text answer. */
|
||||
function nonEmpty(value: string): string | undefined {
|
||||
return value.trim().length === 0 ? 'A value is required' : undefined
|
||||
}
|
||||
|
||||
function packageName(value: string): string | undefined {
|
||||
if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)) {
|
||||
return 'Use a lowercase npm package name'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function projectDirectory(value: string, cwd: string): string | undefined {
|
||||
const empty = nonEmpty(value)
|
||||
if (empty) return empty
|
||||
return existsSync(resolve(cwd, value)) ? 'Target already exists' : undefined
|
||||
}
|
||||
|
||||
const API_KEY_STEP: WizardStep<ProjectAnswerState> = {
|
||||
async run(port, state) {
|
||||
let prefilled = state.args.apiKey
|
||||
while (true) {
|
||||
const apiKey = requireAnswer(await new SecretQuestion({
|
||||
id: 'apiKey',
|
||||
message: state.provider === 'custom' ? 'Custom provider API key' : 'DeepSeek API key',
|
||||
}).resolve(port, prefilled))
|
||||
if (apiKey.length > 0) {
|
||||
state.apiKey = apiKey
|
||||
return
|
||||
}
|
||||
const keepEmpty = requireAnswer(await new ConfirmQuestion({
|
||||
id: 'apiKey.empty',
|
||||
message: 'Keep the API key empty and fill .env later?',
|
||||
initialValue: false,
|
||||
tone: 'warning',
|
||||
}).resolve(port))
|
||||
if (keepEmpty) {
|
||||
state.apiKey = ''
|
||||
return
|
||||
}
|
||||
prefilled = undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
|
||||
questionStep({
|
||||
question: state => new TextQuestion({
|
||||
id: 'directory',
|
||||
message: 'Where should the project be created?',
|
||||
placeholder: 'my-agent',
|
||||
defaultValue: 'my-agent',
|
||||
validate: value => projectDirectory(value, state.cwd),
|
||||
}),
|
||||
prefilled: state => state.args.directory,
|
||||
apply: (state, value) => { state.directory = resolve(state.cwd, value) },
|
||||
}),
|
||||
questionStep({
|
||||
question: (state) => {
|
||||
/* v8 ignore next -- the preceding directory step always populates this state */
|
||||
if (!state.directory) throw new Error('directory must resolve before package name')
|
||||
return new TextQuestion({
|
||||
id: 'name',
|
||||
message: 'Package name',
|
||||
placeholder: basename(state.directory),
|
||||
defaultValue: basename(state.directory),
|
||||
validate: packageName,
|
||||
})
|
||||
},
|
||||
apply: (state, value) => { state.name = value },
|
||||
}),
|
||||
questionStep({
|
||||
question: (state) => {
|
||||
/* v8 ignore next -- the preceding package-name step always populates this state */
|
||||
if (!state.name) throw new Error('package name must resolve before description')
|
||||
return new TextQuestion({
|
||||
id: 'description',
|
||||
message: 'Project description',
|
||||
placeholder: `A DeepSeek Harness agent named ${state.name}`,
|
||||
defaultValue: `A DeepSeek Harness agent named ${state.name}`,
|
||||
validate: nonEmpty,
|
||||
})
|
||||
},
|
||||
prefilled: state => state.args.description,
|
||||
apply: (state, value) => { state.description = value },
|
||||
}),
|
||||
questionStep({
|
||||
question: () => new SelectQuestion<'deepseek-official' | 'custom'>({
|
||||
id: 'provider',
|
||||
message: 'Model provider',
|
||||
options: [
|
||||
{ value: 'deepseek-official', label: 'DeepSeek' },
|
||||
{ value: 'custom', label: 'Custom endpoint (pi-ai)' },
|
||||
],
|
||||
initialValue: 'deepseek-official',
|
||||
}),
|
||||
prefilled: state => state.args.provider,
|
||||
apply: (state, value) => { state.provider = value },
|
||||
}),
|
||||
questionStep({
|
||||
question: () => new TextQuestion({
|
||||
id: 'baseURL', message: 'Custom provider base URL', validate: nonEmpty,
|
||||
}),
|
||||
when: state => state.provider === 'custom' || state.args.baseURL !== undefined,
|
||||
prefilled: state => state.args.baseURL,
|
||||
apply: (state, value) => { state.baseURL = value },
|
||||
}),
|
||||
API_KEY_STEP,
|
||||
questionStep({
|
||||
question: () => new SelectQuestion<RunInterface>({
|
||||
id: 'interface',
|
||||
message: 'Run interface',
|
||||
options: [
|
||||
{ value: 'acp', label: 'ACP automation server' },
|
||||
{ value: 'embed', label: 'Embedded context' },
|
||||
],
|
||||
initialValue: 'acp',
|
||||
}),
|
||||
prefilled: state => state.args.runInterface,
|
||||
apply: (state, value) => { state.runInterface = value },
|
||||
}),
|
||||
]
|
||||
|
||||
function completeAnswers(state: ProjectAnswerState): ProjectAnswers {
|
||||
const keys = ['directory', 'name', 'description', 'provider', 'baseURL', 'apiKey', 'model', 'runInterface'] as const
|
||||
for (const key of keys) {
|
||||
/* v8 ignore next -- the fixed step list above populates every key or throws/cancels first */
|
||||
if (state[key] === undefined) throw new Error(`create question did not resolve ${key}`)
|
||||
}
|
||||
return state as ProjectAnswerState & ProjectAnswers
|
||||
}
|
||||
|
||||
/** Run the fixed project-context sequence in declaration order. */
|
||||
export async function collectProjectAnswers(
|
||||
port: PromptPort,
|
||||
args: CreateArgs,
|
||||
cwd: string,
|
||||
): Promise<ProjectAnswers> {
|
||||
const state: ProjectAnswerState = {
|
||||
args,
|
||||
cwd,
|
||||
baseURL: args.baseURL ?? '',
|
||||
model: args.model ?? 'deepseek-v4-flash',
|
||||
}
|
||||
for (const step of PROJECT_QUESTION_STEPS) await step.run(port, state)
|
||||
return completeAnswers(state)
|
||||
}
|
||||
233
packages/scaffold/create-sdk/src/create-wizard.ts
Normal file
233
packages/scaffold/create-sdk/src/create-wizard.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Declarative create questions with dynamic feature and plugin orchestration.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/create-wizard
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
FeatureConfigurator,
|
||||
ConfirmQuestion,
|
||||
LocalPluginBlueprint,
|
||||
NpmPackageManager,
|
||||
SelectQuestion,
|
||||
featureId,
|
||||
createBuiltinRegistry,
|
||||
createPackageManager,
|
||||
inferPackageManagerName,
|
||||
probePackageManagerVersion,
|
||||
requireAnswer,
|
||||
type FeatureRegistry,
|
||||
type FeatureSelection,
|
||||
type LocalPluginKind,
|
||||
type PackageManager,
|
||||
type PackageManagerName,
|
||||
type PackageManagerVersionProbe,
|
||||
type ProjectCreationRequest,
|
||||
type ProjectProfile,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import type { CreateArgs } from './args.ts'
|
||||
import { collectProjectAnswers, type ProjectAnswers } from './create-questions.ts'
|
||||
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
|
||||
|
||||
/** Fully resolved initializer request and post-create choice. */
|
||||
export interface ResolvedCreateRequest {
|
||||
directory: string
|
||||
request: ProjectCreationRequest
|
||||
install: boolean
|
||||
}
|
||||
|
||||
/** Create-specific orchestration around declarative questions and dynamic selections. */
|
||||
export class CreateWizard {
|
||||
private readonly args: CreateArgs
|
||||
private readonly port: PromptPort
|
||||
private readonly cwd: string
|
||||
private readonly releaseVersion: string
|
||||
private readonly versionProbe: PackageManagerVersionProbe
|
||||
private readonly userAgent: string
|
||||
private readonly linkWorkspaceRoot: string | undefined
|
||||
private readonly featurePlan: readonly FeatureSelection[] | undefined
|
||||
|
||||
/** Bind parsed args and infrastructure to one wizard run. */
|
||||
constructor(options: {
|
||||
args: CreateArgs
|
||||
port: PromptPort
|
||||
cwd?: string
|
||||
releaseVersion: string
|
||||
versionProbe?: PackageManagerVersionProbe
|
||||
userAgent?: string
|
||||
features?: readonly FeatureSelection[]
|
||||
}) {
|
||||
this.args = options.args
|
||||
this.port = options.port
|
||||
this.cwd = resolve(options.cwd ?? process.cwd())
|
||||
this.releaseVersion = options.releaseVersion
|
||||
this.versionProbe = options.versionProbe ?? probePackageManagerVersion
|
||||
/* v8 ignore next -- pnpm supplies npm_config_user_agent while direct invocations may omit it */
|
||||
this.userAgent = options.userAgent ?? process.env.npm_config_user_agent ?? ''
|
||||
this.linkWorkspaceRoot = options.args.linkWorkspace
|
||||
? fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
: undefined
|
||||
this.featurePlan = options.features
|
||||
}
|
||||
|
||||
/** Collect all answers before constructing any project files. */
|
||||
async run(): Promise<ResolvedCreateRequest> {
|
||||
const answers = await this.collectProjectAnswers()
|
||||
const profile = this.provisionalProfile(answers)
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const features = await this.collectFeatures(profile, registry, answers)
|
||||
const localPlugins = await this.collectPlugins()
|
||||
const { manager, install } = await this.collectPackageManager()
|
||||
return {
|
||||
directory: answers.directory,
|
||||
install,
|
||||
request: {
|
||||
name: answers.name,
|
||||
description: answers.description,
|
||||
runtime: { model: answers.model },
|
||||
packageManager: manager,
|
||||
releaseVersion: this.releaseVersion,
|
||||
...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {},
|
||||
features,
|
||||
localPlugins,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async collectProjectAnswers(): Promise<ProjectAnswers> {
|
||||
return collectProjectAnswers(this.port, this.args, this.cwd)
|
||||
}
|
||||
|
||||
private provisionalProfile(answers: ProjectAnswers): ProjectProfile {
|
||||
return {
|
||||
name: answers.name,
|
||||
description: answers.description,
|
||||
runtime: { model: answers.model },
|
||||
runInterface: answers.runInterface,
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: this.releaseVersion,
|
||||
...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {},
|
||||
}
|
||||
}
|
||||
|
||||
private async collectFeatures(
|
||||
profile: ProjectProfile,
|
||||
registry: FeatureRegistry,
|
||||
answers: ProjectAnswers,
|
||||
): Promise<FeatureSelection[]> {
|
||||
const configurator = new FeatureConfigurator(this.port)
|
||||
const selections: FeatureSelection[] = [
|
||||
{
|
||||
id: featureId('provider'),
|
||||
options: [answers.provider],
|
||||
...answers.baseURL ? { values: { baseURL: answers.baseURL } } : {},
|
||||
secrets: { apiKey: answers.apiKey },
|
||||
},
|
||||
{ id: featureId('spine'), options: ['default'] },
|
||||
{ id: featureId('app'), options: [answers.runInterface] },
|
||||
]
|
||||
const configurable = registry.all().filter(feature => feature.id === 'bash'
|
||||
|| feature.id === 'persistence'
|
||||
|| (!feature.required && feature.isApplicable(profile)))
|
||||
const selected = this.featurePlan
|
||||
? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
|
||||
: [...requireAnswer(await this.port.nestedMultiselect({
|
||||
message: 'Select features',
|
||||
options: configurable.map((feature) => {
|
||||
const nested = feature.mode !== 'single'
|
||||
const defaults = new Set(feature.defaultOptions(profile))
|
||||
return {
|
||||
value: feature.id,
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|
||||
|| feature.id === 'skill',
|
||||
...nested ? {
|
||||
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: defaults.has(option.id),
|
||||
})),
|
||||
} : {},
|
||||
}
|
||||
}),
|
||||
}))]
|
||||
if (!this.featurePlan) {
|
||||
for (const { value: id } of [...selected]) {
|
||||
const feature = registry.get(id)
|
||||
for (const suggestedId of feature.suggests) {
|
||||
if (selected.some(item => item.value === suggestedId)) continue
|
||||
const suggested = registry.get(suggestedId)
|
||||
const add = requireAnswer(await new ConfirmQuestion({
|
||||
id: `${feature.id}.${suggested.id}`,
|
||||
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
|
||||
initialValue: true,
|
||||
}).resolve(this.port))
|
||||
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
|
||||
}
|
||||
}
|
||||
}
|
||||
const fixed = new Set(selections.map(selection => selection.id))
|
||||
const choices = new Map<FeatureSelection['id'], readonly string[] | undefined>()
|
||||
for (const feature of registry.all()) {
|
||||
if (feature.required && feature.isApplicable(profile) && !fixed.has(feature.id)) {
|
||||
choices.set(feature.id, feature.defaultOptions(profile))
|
||||
}
|
||||
}
|
||||
for (const choice of selected) {
|
||||
choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
|
||||
}
|
||||
const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
|
||||
for (const [id, options] of choices) {
|
||||
const planned = plannedById.get(id)
|
||||
selections.push(await configurator.configure(
|
||||
registry.get(id),
|
||||
profile,
|
||||
undefined,
|
||||
options,
|
||||
planned?.secrets ?? {},
|
||||
planned?.values ?? {},
|
||||
))
|
||||
}
|
||||
return selections
|
||||
}
|
||||
|
||||
private async collectPlugins(): Promise<LocalPluginBlueprint[]> {
|
||||
const kind = requireAnswer(await new SelectQuestion<LocalPluginKind | 'none'>({
|
||||
id: 'plugins.kind',
|
||||
message: 'Local plugin',
|
||||
options: [
|
||||
{ value: 'none', label: 'No local plugin' },
|
||||
{ value: 'plugin', label: 'Cordis plugin' },
|
||||
{ value: 'tool', label: 'Model-facing tool' },
|
||||
],
|
||||
initialValue: 'none',
|
||||
}).resolve(this.port))
|
||||
return kind === 'none' ? [] : [new LocalPluginBlueprint(kind, kind)]
|
||||
}
|
||||
|
||||
private async collectPackageManager(): Promise<{ manager: PackageManager; install: boolean }> {
|
||||
const inferred = inferPackageManagerName(this.args.packageManager, this.userAgent)
|
||||
const name = requireAnswer(await new SelectQuestion<PackageManagerName>({
|
||||
id: 'packageManager',
|
||||
message: 'Package manager',
|
||||
options: [
|
||||
{ value: 'npm', label: 'npm' },
|
||||
{ value: 'pnpm', label: 'pnpm' },
|
||||
{ value: 'yarn', label: 'Yarn' },
|
||||
],
|
||||
initialValue: inferred ?? 'npm',
|
||||
}).resolve(this.port, inferred))
|
||||
const manager = createPackageManager(name, await this.versionProbe(name, this.cwd))
|
||||
const install = requireAnswer(await new ConfirmQuestion({
|
||||
id: 'install',
|
||||
message: CREATE_TEMPLATES.installQuestion.render(packageManagerTemplateModel(manager)).trimEnd(),
|
||||
initialValue: true,
|
||||
}).resolve(this.port, this.args.install))
|
||||
return { manager, install }
|
||||
}
|
||||
}
|
||||
98
packages/scaffold/create-sdk/src/headless.ts
Normal file
98
packages/scaffold/create-sdk/src/headless.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Headless create input: a structured project spec supplied by an agent or CI
|
||||
* instead of interactive prompts.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/headless
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
|
||||
import type { CreateArgs } from './args.ts'
|
||||
|
||||
/**
|
||||
* Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs}
|
||||
* project answers; `features` is the headless feature plan handed to `CreateWizard`
|
||||
* (the interactive tree/suggests prompts are skipped). Absent required answers make
|
||||
* the run fail loud through `HeadlessPromptPort` rather than blocking.
|
||||
*/
|
||||
interface HeadlessCreateSpec {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek-official' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
interface?: RunInterface
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
features?: readonly FeatureSelection[]
|
||||
}
|
||||
|
||||
/** Resolved headless input: the args the wizard reads plus the feature plan. */
|
||||
export interface ResolvedHeadless {
|
||||
args: CreateArgs
|
||||
features: readonly FeatureSelection[] | undefined
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, source: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${source}: expected a JSON object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Parse and shallow-validate a headless spec from JSON text. */
|
||||
function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */
|
||||
throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
|
||||
}
|
||||
const record = asRecord(parsed, source)
|
||||
if (record.features !== undefined && !Array.isArray(record.features)) {
|
||||
throw new Error(`${source}: "features" must be an array`)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a headless spec from `--config-json` (inline) or `--config` (a JSON file),
|
||||
* returning `undefined` when neither is supplied.
|
||||
* @param args - parsed create args.
|
||||
* @param readFileText - file reader seam for tests.
|
||||
* @returns the resolved args + feature plan, or `undefined` for interactive runs.
|
||||
*/
|
||||
export async function resolveHeadless(
|
||||
args: CreateArgs,
|
||||
readFileText: (path: string) => Promise<string> = path => readFile(path, 'utf8'),
|
||||
): Promise<ResolvedHeadless | undefined> {
|
||||
let text: string
|
||||
let source: string
|
||||
if (args.configJson !== undefined) {
|
||||
text = args.configJson
|
||||
source = '--config-json'
|
||||
} else if (args.config !== undefined) {
|
||||
source = args.config
|
||||
text = await readFileText(args.config)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
const spec = parseHeadlessSpec(text, source)
|
||||
const resolvedArgs: CreateArgs = {
|
||||
...spec.directory === undefined ? {} : { directory: spec.directory },
|
||||
...spec.description === undefined ? {} : { description: spec.description },
|
||||
...spec.provider === undefined ? {} : { provider: spec.provider },
|
||||
...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL },
|
||||
...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey },
|
||||
...spec.model === undefined ? {} : { model: spec.model },
|
||||
...spec.interface === undefined ? {} : { runInterface: spec.interface },
|
||||
...spec.pm === undefined ? {} : { packageManager: spec.pm },
|
||||
...spec.install === undefined ? {} : { install: spec.install },
|
||||
...spec.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
help: false,
|
||||
}
|
||||
return { args: resolvedArgs, features: spec.features }
|
||||
}
|
||||
7
packages/scaffold/create-sdk/src/index.ts
Normal file
7
packages/scaffold/create-sdk/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The create-sdk package is a CLI initializer; its library entry exports no symbols.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk
|
||||
*/
|
||||
|
||||
export {}
|
||||
30
packages/scaffold/create-sdk/src/invariant.ts
Normal file
30
packages/scaffold/create-sdk/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/create-sdk`.
|
||||
* @module @deepseek-ai/create-sdk/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/create-sdk'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'create-sdk-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
41
packages/scaffold/create-sdk/src/project-scaffolder.ts
Normal file
41
packages/scaffold/create-sdk/src/project-scaffolder.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Project creation use case over the shared SDK aggregate and edit session.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/project-scaffolder
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import {
|
||||
SdkProject,
|
||||
createBuiltinRegistry,
|
||||
type ChangeSet,
|
||||
type ProjectCreationRequest,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
|
||||
/** Result of writing one new SDK project. */
|
||||
export interface ScaffoldResult {
|
||||
project: SdkProject
|
||||
changes: ChangeSet
|
||||
}
|
||||
|
||||
/** Create a project entirely in memory, then validate and commit it once. */
|
||||
export async function scaffoldProject(root: string, request: ProjectCreationRequest): Promise<ScaffoldResult> {
|
||||
let targetExists = true
|
||||
try {
|
||||
await stat(root)
|
||||
} catch (error) {
|
||||
/* v8 ignore else -- the other arm requires a filesystem permission/IO fault from stat */
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') targetExists = false
|
||||
/* v8 ignore next -- paired with the ignored defensive stat-error arm above */
|
||||
else throw error
|
||||
}
|
||||
if (targetExists) throw new Error(`target already exists: ${root}`)
|
||||
const project = SdkProject.create(root, request)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const selection of request.features) {
|
||||
edit.installFeature(registry.get(selection.id), selection)
|
||||
}
|
||||
for (const plugin of request.localPlugins) edit.addPlugin(plugin)
|
||||
return edit.commit()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Created {{name}} in {{directory}}
|
||||
@@ -0,0 +1 @@
|
||||
Run {{packageManager}} {{installArgs}} and then build the project?
|
||||
@@ -0,0 +1,5 @@
|
||||
{{#if setupRequired}}
|
||||
Next: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}} && {{packageManager}} start
|
||||
{{else}}
|
||||
Next: cd {{directory}} && {{packageManager}} start
|
||||
{{/if}}
|
||||
@@ -0,0 +1,2 @@
|
||||
Project files are ready, but setup failed: {{error}}
|
||||
Retry: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}}
|
||||
@@ -0,0 +1,14 @@
|
||||
Usage: create-sdk [directory] [options]
|
||||
|
||||
Options:
|
||||
--description <text>
|
||||
--provider <deepseek-official|custom>
|
||||
--base-url <url>
|
||||
--api-key <key>
|
||||
--model <name>
|
||||
--interface <acp|embed>
|
||||
--pm <npm|pnpm|yarn>
|
||||
--install / --no-install
|
||||
--config <path>
|
||||
--config-json <json>
|
||||
--json
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Package-owned terminal templates for create-sdk.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/templates/create-templates
|
||||
*/
|
||||
|
||||
import {
|
||||
TextTemplate,
|
||||
type PackageManager,
|
||||
type PackageManagerName,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
|
||||
interface CreatedTemplateModel {
|
||||
name: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
interface NextStepsTemplateModel extends PackageManagerTemplateModel {
|
||||
directory: string
|
||||
setupRequired: boolean
|
||||
}
|
||||
|
||||
interface SetupFailureTemplateModel extends PackageManagerTemplateModel {
|
||||
directory: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/** Package-manager execution data consumed by create-sdk templates. */
|
||||
export interface PackageManagerTemplateModel {
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
buildArgs: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Map package-manager execution data into terminal-template fields.
|
||||
* @param manager - selected package-manager strategy.
|
||||
* @returns executable name and operation arguments.
|
||||
*/
|
||||
export function packageManagerTemplateModel(manager: PackageManager): PackageManagerTemplateModel {
|
||||
return {
|
||||
packageManager: manager.name,
|
||||
installArgs: manager.installCommand().join(' '),
|
||||
buildArgs: manager.buildCommand().join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
/** Compiled create-sdk terminal templates. */
|
||||
export const CREATE_TEMPLATES = {
|
||||
usage: TextTemplate.fromFile<Record<string, never>>(new URL('./assets/usage.txt.tpl', import.meta.url)),
|
||||
created: TextTemplate.fromFile<CreatedTemplateModel>(new URL('./assets/created.txt.tpl', import.meta.url)),
|
||||
nextSteps: TextTemplate.fromFile<NextStepsTemplateModel>(new URL('./assets/next-steps.txt.tpl', import.meta.url)),
|
||||
setupFailure: TextTemplate.fromFile<SetupFailureTemplateModel>(
|
||||
new URL('./assets/setup-failure.txt.tpl', import.meta.url),
|
||||
),
|
||||
installQuestion: TextTemplate.fromFile<PackageManagerTemplateModel>(
|
||||
new URL('./assets/install-question.txt.tpl', import.meta.url),
|
||||
),
|
||||
} as const
|
||||
28
packages/scaffold/create-sdk/tests/built-artifacts.e2e.ts
Normal file
28
packages/scaffold/create-sdk/tests/built-artifacts.e2e.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const createBin = join(repoRoot, 'packages/scaffold/create-sdk/lib/bin.js')
|
||||
const scriptsBin = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js')
|
||||
|
||||
describe.skipIf(!existsSync(createBin) || !existsSync(scriptsBin))(
|
||||
'SDK built artifacts',
|
||||
() => {
|
||||
it('runs the published dsh-sdk bin help path under plain Node', async () => {
|
||||
const result = await execFileAsync(process.execPath, [scriptsBin, '--help'], { encoding: 'utf8' })
|
||||
expect(result.stdout).toContain('Usage: dsh-sdk <command>')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
|
||||
it('runs the published create-sdk bin help path under plain Node', async () => {
|
||||
const result = await execFileAsync(process.execPath, [createBin, '--help'], { encoding: 'utf8' })
|
||||
expect(result.stdout).toContain('Usage: create-sdk [directory]')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
},
|
||||
)
|
||||
391
packages/scaffold/create-sdk/tests/create.snapshot.ts
Normal file
391
packages/scaffold/create-sdk/tests/create.snapshot.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
featureId,
|
||||
createPackageManager,
|
||||
type NestedMultiSelectValue,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import type {
|
||||
ConfirmPromptRequest,
|
||||
MultiSelectPromptRequest,
|
||||
NestedMultiSelectRequest,
|
||||
PromptOutcome,
|
||||
SecretPromptRequest,
|
||||
SelectPromptRequest,
|
||||
TextPromptRequest,
|
||||
} from '../../helper/src/questions/prompt-port.ts'
|
||||
import { parseCreateArgs } from '../src/args.ts'
|
||||
import { CreateWizard } from '../src/create-wizard.ts'
|
||||
import { CREATE_TEMPLATES, packageManagerTemplateModel } from '../src/templates/create-templates.ts'
|
||||
|
||||
class RecordingPort implements PromptPort {
|
||||
readonly transcript: unknown[] = []
|
||||
readonly #answers: unknown[]
|
||||
|
||||
constructor(answers: unknown[]) { this.#answers = [...answers] }
|
||||
|
||||
answer<T>(record: unknown): Promise<PromptOutcome<T>> {
|
||||
this.transcript.push(record)
|
||||
return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T })
|
||||
}
|
||||
|
||||
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return this.answer({
|
||||
kind: 'text',
|
||||
message: request.message,
|
||||
defaultValue: request.defaultValue,
|
||||
initialValue: request.initialValue,
|
||||
})
|
||||
}
|
||||
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return this.answer({ kind: 'secret', message: request.message })
|
||||
}
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
|
||||
return this.answer({
|
||||
kind: 'select', message: request.message, options: request.options.map(option => option.label),
|
||||
initialValue: request.initialValue,
|
||||
})
|
||||
}
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
return this.answer({
|
||||
kind: 'multiselect', message: request.message, options: request.options.map(option => option.label),
|
||||
initialValues: request.initialValues,
|
||||
})
|
||||
}
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
|
||||
return this.answer({ kind: 'confirm', message: request.message, initialValue: request.initialValue })
|
||||
}
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return this.answer({
|
||||
kind: 'nested-multiselect',
|
||||
message: request.message,
|
||||
options: request.options.map(option => ({
|
||||
label: option.label,
|
||||
required: option.required,
|
||||
default: option.default,
|
||||
choices: option.choices?.map(choice => choice.label),
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
|
||||
it('renders package-manager-specific setup commands', () => {
|
||||
const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
|
||||
expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')
|
||||
expect(CREATE_TEMPLATES.setupFailure.render({
|
||||
directory: '/workspace/agent',
|
||||
error: 'offline',
|
||||
...model,
|
||||
})).toContain('yarn install && yarn build')
|
||||
})
|
||||
|
||||
it('pins the full unresolved question order and completion messages', async () => {
|
||||
const port = new RecordingPort([
|
||||
'my-agent',
|
||||
'my-agent',
|
||||
'Snapshot agent',
|
||||
'deepseek-official',
|
||||
'secret-key',
|
||||
'acp',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['jsonl'] },
|
||||
{ value: featureId('hmr'), choices: [] },
|
||||
{ value: featureId('web'), choices: ['exa'] },
|
||||
{ value: featureId('workflow'), choices: [] },
|
||||
],
|
||||
true,
|
||||
'exa-key',
|
||||
'none',
|
||||
'npm',
|
||||
false,
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([]),
|
||||
port,
|
||||
cwd: '/workspace',
|
||||
releaseVersion: '0.0.1',
|
||||
userAgent: '',
|
||||
versionProbe: async () => '10.0.0',
|
||||
}).run()
|
||||
expect({
|
||||
prompts: port.transcript,
|
||||
result: {
|
||||
directory: resolved.directory,
|
||||
name: resolved.request.name,
|
||||
manager: resolved.request.packageManager.name,
|
||||
install: resolved.install,
|
||||
features: resolved.request.features.map(item => ({ id: item.id, options: item.options })),
|
||||
},
|
||||
messages: {
|
||||
created: CREATE_TEMPLATES.created.render({
|
||||
name: resolved.request.name,
|
||||
directory: resolved.directory,
|
||||
}),
|
||||
next: CREATE_TEMPLATES.nextSteps.render({
|
||||
directory: resolved.directory,
|
||||
setupRequired: false,
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
}),
|
||||
failure: CREATE_TEMPLATES.setupFailure.render({
|
||||
directory: resolved.directory,
|
||||
error: String(new Error('offline')),
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
}),
|
||||
},
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"messages": {
|
||||
"created": "Created my-agent in /workspace/my-agent
|
||||
",
|
||||
"failure": "Project files are ready, but setup failed: Error: offline
|
||||
Retry: cd /workspace/my-agent && npm install && npm run build
|
||||
",
|
||||
"next": "Next: cd /workspace/my-agent && npm start
|
||||
",
|
||||
},
|
||||
"prompts": [
|
||||
{
|
||||
"defaultValue": "my-agent",
|
||||
"initialValue": undefined,
|
||||
"kind": "text",
|
||||
"message": "Where should the project be created?",
|
||||
},
|
||||
{
|
||||
"defaultValue": "my-agent",
|
||||
"initialValue": undefined,
|
||||
"kind": "text",
|
||||
"message": "Package name",
|
||||
},
|
||||
{
|
||||
"defaultValue": "A DeepSeek Harness agent named my-agent",
|
||||
"initialValue": undefined,
|
||||
"kind": "text",
|
||||
"message": "Project description",
|
||||
},
|
||||
{
|
||||
"initialValue": "deepseek-official",
|
||||
"kind": "select",
|
||||
"message": "Model provider",
|
||||
"options": [
|
||||
"DeepSeek",
|
||||
"Custom endpoint (pi-ai)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "secret",
|
||||
"message": "DeepSeek API key",
|
||||
},
|
||||
{
|
||||
"initialValue": "acp",
|
||||
"kind": "select",
|
||||
"message": "Run interface",
|
||||
"options": [
|
||||
"ACP automation server",
|
||||
"Embedded context",
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "nested-multiselect",
|
||||
"message": "Select features",
|
||||
"options": [
|
||||
{
|
||||
"choices": [
|
||||
"Local executor",
|
||||
"Sandboxed executor",
|
||||
],
|
||||
"default": true,
|
||||
"label": "Command execution",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
"JSONL files",
|
||||
"SQLite database",
|
||||
],
|
||||
"default": true,
|
||||
"label": "Durable session storage",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": true,
|
||||
"label": "Hot-module reload",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": true,
|
||||
"label": "Read, write, and edit local files",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": true,
|
||||
"label": "Model-facing task tracking",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": true,
|
||||
"label": "Local skill discovery",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
"DeepSeek search",
|
||||
"Exa search",
|
||||
"Perplexity search",
|
||||
"Fetch only",
|
||||
],
|
||||
"default": false,
|
||||
"label": "Web search and fetch tools",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
"Fresh child agent",
|
||||
"Fork parent history",
|
||||
],
|
||||
"default": false,
|
||||
"label": "Delegate work to child agents",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"label": "Scripted multi-agent workflows",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"label": "Automatic context compaction",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": [
|
||||
"Claude Code hooks",
|
||||
"Codex hooks",
|
||||
],
|
||||
"default": false,
|
||||
"label": "Run Claude Code or Codex hooks",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"label": "Loop-hygiene reminders",
|
||||
"required": false,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"label": "Tool timeout policy",
|
||||
"required": false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"initialValue": true,
|
||||
"kind": "confirm",
|
||||
"message": "Add the recommended tool timeout policy for web search and fetch tools?",
|
||||
},
|
||||
{
|
||||
"kind": "secret",
|
||||
"message": "Exa API key",
|
||||
},
|
||||
{
|
||||
"initialValue": "none",
|
||||
"kind": "select",
|
||||
"message": "Local plugin",
|
||||
"options": [
|
||||
"No local plugin",
|
||||
"Cordis plugin",
|
||||
"Model-facing tool",
|
||||
],
|
||||
},
|
||||
{
|
||||
"initialValue": "npm",
|
||||
"kind": "select",
|
||||
"message": "Package manager",
|
||||
"options": [
|
||||
"npm",
|
||||
"pnpm",
|
||||
"Yarn",
|
||||
],
|
||||
},
|
||||
{
|
||||
"initialValue": true,
|
||||
"kind": "confirm",
|
||||
"message": "Run npm install and then build the project?",
|
||||
},
|
||||
],
|
||||
"result": {
|
||||
"directory": "/workspace/my-agent",
|
||||
"features": [
|
||||
{
|
||||
"id": "provider",
|
||||
"options": [
|
||||
"deepseek-official",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "spine",
|
||||
"options": [
|
||||
"default",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "app",
|
||||
"options": [
|
||||
"acp",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "bash",
|
||||
"options": [
|
||||
"local",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "persistence",
|
||||
"options": [
|
||||
"jsonl",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "hmr",
|
||||
"options": [
|
||||
"default",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "web",
|
||||
"options": [
|
||||
"exa",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "workflow",
|
||||
"options": [
|
||||
"workerthread",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "timeout-policy",
|
||||
"options": [
|
||||
"default",
|
||||
],
|
||||
},
|
||||
],
|
||||
"install": false,
|
||||
"manager": "npm",
|
||||
"name": "my-agent",
|
||||
},
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
674
packages/scaffold/create-sdk/tests/create.spec.ts
Normal file
674
packages/scaffold/create-sdk/tests/create.spec.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
HeadlessPromptPort,
|
||||
LocalPluginBlueprint,
|
||||
featureId,
|
||||
NodeCommandRunner,
|
||||
NpmPackageManager,
|
||||
type FeatureSelection,
|
||||
type NestedMultiSelectValue,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import type {
|
||||
ConfirmPromptRequest,
|
||||
MultiSelectPromptRequest,
|
||||
NestedMultiSelectRequest,
|
||||
PromptOutcome,
|
||||
SecretPromptRequest,
|
||||
SelectPromptRequest,
|
||||
TextPromptRequest,
|
||||
} from '../../helper/src/questions/prompt-port.ts'
|
||||
import { parseCreateArgs } from '../src/args.ts'
|
||||
import {
|
||||
createProject,
|
||||
readCreateSdkVersion,
|
||||
runCreateCommand,
|
||||
type CreateCommandContext,
|
||||
} from '../src/command.ts'
|
||||
import { CreateWizard } from '../src/create-wizard.ts'
|
||||
import { resolveHeadless } from '../src/headless.ts'
|
||||
import { scaffoldProject } from '../src/project-scaffolder.ts'
|
||||
|
||||
class ScriptedPort implements PromptPort {
|
||||
readonly requests: string[] = []
|
||||
readonly #answers: unknown[]
|
||||
|
||||
constructor(answers: unknown[]) {
|
||||
this.#answers = [...answers]
|
||||
}
|
||||
|
||||
answer<T>(message: string): Promise<PromptOutcome<T>> {
|
||||
this.requests.push(message)
|
||||
const value = this.#answers.shift()
|
||||
return Promise.resolve(value === ScriptedPort.cancel
|
||||
? { status: 'cancelled' }
|
||||
: { status: 'answered', value: value as T })
|
||||
}
|
||||
|
||||
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
const outcome = await this.answer<string>(request.message)
|
||||
if (outcome.status === 'cancelled') return outcome
|
||||
const value = outcome.value || request.defaultValue || ''
|
||||
const diagnostic = request.validate?.(value)
|
||||
if (diagnostic) throw new Error(diagnostic)
|
||||
return { status: 'answered', value }
|
||||
}
|
||||
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.answer(request.message) }
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.answer(request.message) }
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
return this.answer(request.message)
|
||||
}
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.answer(request.message) }
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return this.answer(request.message)
|
||||
}
|
||||
|
||||
static readonly cancel = Symbol('cancel')
|
||||
}
|
||||
|
||||
const temporary: string[] = []
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
|
||||
interface GeneratedPackageManifest {
|
||||
scripts?: Record<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
interface GeneratedTsConfig {
|
||||
compilerOptions: {
|
||||
types?: readonly string[]
|
||||
}
|
||||
}
|
||||
|
||||
function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest {
|
||||
return JSON.parse(text) as GeneratedPackageManifest
|
||||
}
|
||||
|
||||
function parseGeneratedTsConfig(text: string): GeneratedTsConfig {
|
||||
return JSON.parse(text) as GeneratedTsConfig
|
||||
}
|
||||
|
||||
function commandContext(
|
||||
cwd: string,
|
||||
port?: PromptPort,
|
||||
setup?: CreateCommandContext['setup'],
|
||||
): CreateCommandContext & { readStdout: () => string; readStderr: () => string } {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
|
||||
const output = Object.assign(new Writable({
|
||||
write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
|
||||
}), { isTTY: true }) as unknown as NodeJS.WriteStream
|
||||
const error = new Writable({
|
||||
write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
|
||||
}) as unknown as NodeJS.WriteStream
|
||||
return {
|
||||
cwd,
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: error,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
...port ? { port } : {},
|
||||
...setup ? { setup } : {},
|
||||
readStdout: () => stdout,
|
||||
readStderr: () => stderr,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('create arguments', () => {
|
||||
it('parses public options and the private repository link mode', () => {
|
||||
expect(parseCreateArgs([
|
||||
'agent', '--description=demo', '--provider', 'deepseek-official', '--base-url=https://api.example',
|
||||
'--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
|
||||
'--link-workspace',
|
||||
])).toEqual({
|
||||
directory: 'agent',
|
||||
description: 'demo',
|
||||
provider: 'deepseek-official',
|
||||
baseURL: 'https://api.example',
|
||||
apiKey: 'key',
|
||||
model: 'm',
|
||||
runInterface: 'acp',
|
||||
packageManager: 'pnpm',
|
||||
install: false,
|
||||
linkWorkspace: true,
|
||||
help: false,
|
||||
})
|
||||
expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true)
|
||||
expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
|
||||
expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
|
||||
expect(parseCreateArgs(['--help']).help).toBe(true)
|
||||
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed')
|
||||
expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
|
||||
expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
|
||||
})
|
||||
|
||||
it('validates empty directories and package names', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-validation-'))
|
||||
temporary.push(root)
|
||||
await expect(new CreateWizard({
|
||||
args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root,
|
||||
releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
|
||||
}).run()).rejects.toThrow('A value is required')
|
||||
await expect(new CreateWizard({
|
||||
args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root,
|
||||
releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
|
||||
}).run()).rejects.toThrow('lowercase npm package name')
|
||||
})
|
||||
|
||||
it('rejects an existing target before asking project questions', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-'))
|
||||
temporary.push(cwd)
|
||||
await mkdir(join(cwd, 'taken'))
|
||||
const port = new ScriptedPort([])
|
||||
const wizard = new CreateWizard({
|
||||
args: parseCreateArgs(['taken']),
|
||||
port,
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
})
|
||||
await expect(wizard.run()).rejects.toThrow('directory: Target already exists')
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CreateWizard and scaffolder', () => {
|
||||
it('asks only unresolved questions in requirement-safe order', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-'))
|
||||
temporary.push(cwd)
|
||||
const port = new ScriptedPort([
|
||||
'my-agent',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['sqlite'] },
|
||||
{ value: featureId('hmr'), choices: [] },
|
||||
{ value: featureId('fs'), choices: [] },
|
||||
{ value: featureId('web'), choices: ['exa'] },
|
||||
],
|
||||
false,
|
||||
'exa-key',
|
||||
'tool',
|
||||
])
|
||||
const args = parseCreateArgs([
|
||||
'my-agent',
|
||||
'--description=demo',
|
||||
'--provider=deepseek-official',
|
||||
'--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash',
|
||||
'--interface=acp',
|
||||
'--pm=npm',
|
||||
'--no-install',
|
||||
'--link-workspace',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args,
|
||||
port,
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
}).run()
|
||||
expect(port.requests).toEqual([
|
||||
'Package name',
|
||||
'Select features',
|
||||
'Add the recommended tool timeout policy for web search and fetch tools?',
|
||||
'Exa API key',
|
||||
'Local plugin',
|
||||
])
|
||||
expect(resolved.install).toBe(false)
|
||||
expect(resolved.request.packageManager.name).toBe('npm')
|
||||
expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot)
|
||||
expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' })
|
||||
expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
|
||||
options: ['exa'], secrets: { apiKey: 'exa-key' },
|
||||
})
|
||||
expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
|
||||
})
|
||||
|
||||
it('runs headlessly from a feature plan without reaching the terminal', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-headless-'))
|
||||
temporary.push(cwd)
|
||||
const features: FeatureSelection[] = [
|
||||
{ id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } },
|
||||
{ id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
|
||||
]
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash', '--interface=acp', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
features,
|
||||
}).run()
|
||||
expect(resolved.install).toBe(false)
|
||||
expect(resolved.request.localPlugins).toEqual([])
|
||||
expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
|
||||
options: ['exa'], secrets: { apiKey: 'exa-key' },
|
||||
})
|
||||
expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] })
|
||||
expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
|
||||
secrets: { apiKey: 'deepseek-key' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-string feature value in a headless plan', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-'))
|
||||
temporary.push(cwd)
|
||||
const features = [
|
||||
{ id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } },
|
||||
] as unknown as FeatureSelection[]
|
||||
await expect(new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=k',
|
||||
'--model=m', '--interface=acp', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port: new HeadlessPromptPort(),
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
features,
|
||||
}).run()).rejects.toThrow('must be a string')
|
||||
})
|
||||
|
||||
it('writes the project once and refuses every existing target', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
|
||||
temporary.push(root)
|
||||
const request = {
|
||||
name: 'agent',
|
||||
description: 'demo',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['embed'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
],
|
||||
localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')],
|
||||
}
|
||||
const target = join(root, 'project')
|
||||
const result = await scaffoldProject(target, request)
|
||||
expect(result.changes.changedFiles).toContain('README.md')
|
||||
const index = await readFile(join(target, 'index.ts'), 'utf8')
|
||||
expect(index).toContain('SdkBootContext')
|
||||
expect(index).toContain('ctx.agents.create')
|
||||
expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
|
||||
expect(index).not.toContain('AgentId')
|
||||
const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
|
||||
const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
|
||||
expect(tsconfig.compilerOptions.types).toEqual(['node'])
|
||||
expect(manifest.scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
build: 'dsh-sdk build',
|
||||
typecheck: 'tsc -b',
|
||||
start: 'dsh-sdk start index.js',
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
|
||||
expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
|
||||
expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
|
||||
const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
|
||||
expect(cordis).toMatch(/^- id:/)
|
||||
expect(cordis).not.toMatch(/^\[/)
|
||||
const occupied = join(root, 'occupied')
|
||||
await mkdir(occupied)
|
||||
await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
|
||||
await writeFile(join(occupied, 'keep'), 'x')
|
||||
await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
|
||||
})
|
||||
|
||||
it('installs workflow requirements before validating the next feature', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
|
||||
temporary.push(cwd)
|
||||
const port = new ScriptedPort([
|
||||
'workflow-agent',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['jsonl'] },
|
||||
{ value: featureId('workflow'), choices: [] },
|
||||
],
|
||||
'none',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'workflow-agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
}).run()
|
||||
const result = await scaffoldProject(resolved.directory, resolved.request)
|
||||
expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
|
||||
expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
|
||||
})
|
||||
|
||||
it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
|
||||
temporary.push(cwd)
|
||||
const port = new ScriptedPort([
|
||||
'empty-key-agent',
|
||||
'',
|
||||
true,
|
||||
[{ value: featureId('persistence'), choices: ['jsonl'] }],
|
||||
'none',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'empty-key-agent', '--description=test', '--provider=deepseek-official',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
}).run()
|
||||
await scaffoldProject(resolved.directory, resolved.request)
|
||||
expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
|
||||
'# Required before the first model request.\nDEEPSEEK_API_KEY=\n',
|
||||
)
|
||||
expect(port.requests).toContain('Keep the API key empty and fill .env later?')
|
||||
})
|
||||
|
||||
it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
|
||||
temporary.push(cwd)
|
||||
const port = new ScriptedPort([
|
||||
'custom-agent',
|
||||
'test custom provider',
|
||||
'custom',
|
||||
'https://provider.example/v1',
|
||||
'', false, 'custom-key',
|
||||
'embed',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['jsonl'] },
|
||||
{ value: featureId('web'), choices: ['deepseek-official'] },
|
||||
],
|
||||
true,
|
||||
'none',
|
||||
'npm',
|
||||
false,
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs(['custom-agent']),
|
||||
port,
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
userAgent: '',
|
||||
}).run()
|
||||
expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
|
||||
options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
|
||||
})
|
||||
expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not re-suggest an already selected feature', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
|
||||
temporary.push(cwd)
|
||||
const port = new ScriptedPort([
|
||||
'agent',
|
||||
[
|
||||
{ value: featureId('persistence'), choices: ['jsonl'] },
|
||||
{ value: featureId('web'), choices: ['deepseek-official'] },
|
||||
{ value: featureId('timeout-policy'), choices: ['default'] },
|
||||
],
|
||||
'none',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
cwd,
|
||||
releaseVersion: '0.0.1',
|
||||
versionProbe: async () => '10.0.0',
|
||||
}).run()
|
||||
expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('uses process defaults when constructor infrastructure is omitted', async () => {
|
||||
const name = `default-infra-${String(process.pid)}`
|
||||
const port = new ScriptedPort([
|
||||
name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
name, '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', '--no-install',
|
||||
]),
|
||||
port,
|
||||
releaseVersion: '0.0.1',
|
||||
}).run()
|
||||
expect(resolved.request.packageManager.name).toBe('npm')
|
||||
})
|
||||
|
||||
it('reads the release batch from the initializer package', async () => {
|
||||
await expect(readCreateSdkVersion()).resolves.toBe('0.0.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create command composition', () => {
|
||||
const argv = (directory: string, install: boolean): string[] => [
|
||||
directory, '--description=test', '--provider=deepseek-official', '--api-key=key',
|
||||
'--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
|
||||
]
|
||||
|
||||
it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
|
||||
temporary.push(root)
|
||||
const context = commandContext(root)
|
||||
context.stdin.isTTY = false
|
||||
context.stdout.isTTY = false
|
||||
await expect(createProject(['--help'], context)).resolves.toBeUndefined()
|
||||
expect(context.readStdout()).toContain('Usage: create-sdk')
|
||||
expect(context.readStdout()).toContain('--config-json <json>')
|
||||
expect(context.readStdout()).not.toContain('--link-workspace')
|
||||
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
|
||||
context.stdin.isTTY = true
|
||||
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
|
||||
})
|
||||
|
||||
it('creates headlessly from --config-json with no TTY', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
|
||||
temporary.push(root)
|
||||
const spec = JSON.stringify({
|
||||
directory: 'agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
|
||||
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
|
||||
features: [{ id: 'persistence', options: ['jsonl'] }],
|
||||
})
|
||||
const context = commandContext(root)
|
||||
context.stdin.isTTY = false
|
||||
context.stdout.isTTY = false
|
||||
const result = await createProject(['--config-json', spec], context)
|
||||
expect(result?.project.root).toBe(join(root, 'agent'))
|
||||
})
|
||||
|
||||
it('emits NDJSON lifecycle events under --json', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-headless-json-'))
|
||||
temporary.push(root)
|
||||
const base = {
|
||||
description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
|
||||
}
|
||||
const ok = commandContext(root)
|
||||
ok.stdin.isTTY = false
|
||||
ok.stdout.isTTY = false
|
||||
const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek-official', apiKey: 'key', features: [] })
|
||||
await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
|
||||
expect(ok.readStdout()).toContain('{"type":"done"}')
|
||||
// stdout stays pure NDJSON: every line parses, human progress goes to stderr
|
||||
for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) {
|
||||
expect(() => { JSON.parse(line) }).not.toThrow()
|
||||
}
|
||||
expect(ok.readStderr()).toContain('Created done-agent')
|
||||
expect(ok.readStderr()).toContain('Next: cd')
|
||||
|
||||
const missing = commandContext(root)
|
||||
missing.stdin.isTTY = false
|
||||
missing.stdout.isTTY = false
|
||||
const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] })
|
||||
await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1)
|
||||
expect(missing.readStdout()).toContain('"type":"action-required"')
|
||||
|
||||
const broken = commandContext(root)
|
||||
broken.stdin.isTTY = false
|
||||
broken.stdout.isTTY = false
|
||||
await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1)
|
||||
expect(broken.readStdout()).toContain('"type":"error"')
|
||||
|
||||
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
|
||||
await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1)
|
||||
expect(cancelled.readStdout()).toContain('"reason":"cancelled"')
|
||||
})
|
||||
|
||||
it('creates through an injected prompt port and delegates optional setup', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
|
||||
temporary.push(root)
|
||||
const port = new ScriptedPort([
|
||||
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||
])
|
||||
let setupDirectory = ''
|
||||
const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
|
||||
const result = await createProject(argv('agent', true), context)
|
||||
expect(result?.project.root).toBe(join(root, 'agent'))
|
||||
expect(setupDirectory).toBe(join(root, 'agent'))
|
||||
expect(context.readStdout()).toContain('Created agent')
|
||||
expect(context.readStdout()).toContain('Next: cd')
|
||||
const noInstall = commandContext(root, new ScriptedPort([
|
||||
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||
]))
|
||||
await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
|
||||
expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
|
||||
})
|
||||
|
||||
it('uses the package manager setup path when no setup override is supplied', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
|
||||
temporary.push(root)
|
||||
const port = new ScriptedPort([
|
||||
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||
])
|
||||
const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
|
||||
const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
|
||||
const context = commandContext(root, port)
|
||||
delete context.releaseVersion
|
||||
delete context.versionProbe
|
||||
await createProject(argv('agent', true), context)
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
expect(build).toHaveBeenCalledOnce()
|
||||
const spec = JSON.stringify({
|
||||
directory: 'json-agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
|
||||
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
|
||||
})
|
||||
const json = commandContext(root)
|
||||
json.stdin.isTTY = false
|
||||
json.stdout.isTTY = false
|
||||
await createProject(['--config-json', spec, '--json'], json)
|
||||
// json mode hands install/build a runner that redirects child output to stderr
|
||||
expect(install).toHaveBeenCalledTimes(2)
|
||||
expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner)
|
||||
install.mockRestore()
|
||||
build.mockRestore()
|
||||
})
|
||||
|
||||
it('reports setup failures after preserving generated files', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
|
||||
temporary.push(root)
|
||||
const port = new ScriptedPort([
|
||||
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||
])
|
||||
const context = commandContext(root, port, async () => { throw new Error('offline') })
|
||||
await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
|
||||
expect(context.readStderr()).toContain('Project files are ready, but setup failed')
|
||||
expect(context.readStderr()).toContain('npm install && npm run build')
|
||||
const stringFailure = commandContext(root, new ScriptedPort([
|
||||
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
|
||||
]), async () => { throw 'offline-string' })
|
||||
await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
|
||||
expect(stringFailure.readStderr()).toContain('offline-string')
|
||||
})
|
||||
|
||||
it('maps cancellation and ordinary errors to command exit codes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
|
||||
temporary.push(root)
|
||||
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
|
||||
await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
|
||||
expect(cancelled.readStderr()).toContain('cancelled')
|
||||
const invalid = commandContext(root)
|
||||
await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
|
||||
expect(invalid.readStderr()).toContain('unknown option')
|
||||
const help = commandContext(root)
|
||||
await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHeadless', () => {
|
||||
it('returns undefined without a config source', async () => {
|
||||
expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps every inline --config-json field into args plus the feature plan', async () => {
|
||||
const spec = JSON.stringify({
|
||||
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
|
||||
model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true,
|
||||
features: [{ id: 'todo', options: ['default'] }],
|
||||
})
|
||||
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec]))
|
||||
expect(resolved?.args).toMatchObject({
|
||||
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
|
||||
model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false,
|
||||
})
|
||||
expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }])
|
||||
})
|
||||
|
||||
it('reads --config from a file via the injected reader and omits absent fields', async () => {
|
||||
const resolved = await resolveHeadless(
|
||||
parseCreateArgs(['--config', '/spec.json']),
|
||||
async () => JSON.stringify({ description: 'from-file' }),
|
||||
)
|
||||
expect(resolved?.args.description).toBe('from-file')
|
||||
expect(resolved?.args.directory).toBeUndefined()
|
||||
expect(resolved?.args.linkWorkspace).toBeUndefined()
|
||||
expect(resolved?.features).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads --config from disk with the default reader', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-'))
|
||||
temporary.push(dir)
|
||||
const file = join(dir, 'spec.json')
|
||||
await writeFile(file, JSON.stringify({ description: 'on-disk' }))
|
||||
const resolved = await resolveHeadless(parseCreateArgs(['--config', file]))
|
||||
expect(resolved?.args.description).toBe('on-disk')
|
||||
})
|
||||
|
||||
it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => {
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array')
|
||||
})
|
||||
|
||||
it('accepts a minimal spec, leaving unspecified answers undefined', async () => {
|
||||
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}']))
|
||||
expect(resolved?.args.directory).toBe('x')
|
||||
expect(resolved?.args.description).toBeUndefined()
|
||||
expect(resolved?.features).toBeUndefined()
|
||||
})
|
||||
})
|
||||
126
packages/scaffold/create-sdk/tests/link-workspace.e2e.ts
Normal file
126
packages/scaffold/create-sdk/tests/link-workspace.e2e.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
LocalPluginBlueprint,
|
||||
featureId,
|
||||
createPackageManager,
|
||||
type PackageManagerName,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { scrubEnvironment } from '../../helper/src/package-managers/package-manager.ts'
|
||||
import { scaffoldProject } from '../src/project-scaffolder.ts'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const builtScripts = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js')
|
||||
const temporary: string[] = []
|
||||
|
||||
function resolveCorepackHome(): string {
|
||||
return process.env.COREPACK_HOME ?? join(
|
||||
process.env.XDG_CACHE_HOME
|
||||
?? process.env.LOCALAPPDATA
|
||||
?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'),
|
||||
'node/corepack',
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function managerVersion(name: PackageManagerName): Promise<string | undefined> {
|
||||
try {
|
||||
return (await execFileAsync(name, ['--version'], { encoding: 'utf8' })).stdout.trim()
|
||||
} catch {
|
||||
// An unavailable optional manager skips only its own live-link case.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const managers: PackageManagerName[] = ['npm', 'pnpm', 'yarn']
|
||||
|
||||
describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () => {
|
||||
for (const name of managers) {
|
||||
it(`${name}: installs the local closure and resolves plugin TypeScript in dev`, async (context) => {
|
||||
const version = await managerVersion(name)
|
||||
if (!version) {
|
||||
context.skip()
|
||||
return
|
||||
}
|
||||
const parent = await mkdtemp(join(tmpdir(), `dsh-link-${name}-`))
|
||||
const root = join(parent, 'project')
|
||||
temporary.push(parent)
|
||||
const manager = createPackageManager(name, version)
|
||||
await scaffoldProject(root, {
|
||||
name: `linked-${name}`,
|
||||
description: 'link e2e',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
packageManager: manager,
|
||||
releaseVersion: '0.0.1',
|
||||
linkWorkspaceRoot: repoRoot,
|
||||
features: [
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'test-key' } },
|
||||
{ id: featureId('bash'), options: ['local'] },
|
||||
{ id: featureId('app'), options: ['embed'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
],
|
||||
localPlugins: [new LocalPluginBlueprint('probe', 'plugin')],
|
||||
})
|
||||
await writeFile(join(root, 'plugins/probe/src/index.ts'), `
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import type { Context } from 'cordis'
|
||||
export const name = 'probe'
|
||||
export function apply(_ctx: Context): void {
|
||||
writeFileSync(new URL('../../../plugin-loaded', import.meta.url), 'loaded\\n')
|
||||
}
|
||||
`)
|
||||
const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name)
|
||||
const pnpmStore = name === 'pnpm'
|
||||
? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim()
|
||||
: undefined
|
||||
const commandEnvironment = {
|
||||
...scrubEnvironment(),
|
||||
COREPACK_HOME: resolveCorepackHome(),
|
||||
...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') },
|
||||
XDG_DATA_HOME: join(cacheRoot, 'data'),
|
||||
npm_config_cache: join(cacheRoot, 'npm'),
|
||||
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
|
||||
// A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
|
||||
...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
|
||||
}
|
||||
await execFileAsync(name, manager.installCommand(), {
|
||||
cwd: root,
|
||||
env: commandEnvironment,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
})
|
||||
await execFileAsync(name, manager.buildCommand(), {
|
||||
cwd: root,
|
||||
env: commandEnvironment,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
})
|
||||
expect(existsSync(join(root, 'index.js'))).toBe(true)
|
||||
expect(existsSync(join(root, 'plugins/probe/lib/index.js'))).toBe(true)
|
||||
const dshSdk = join(root, 'node_modules/@deepseek-ai/dsh-scripts/lib/bin.js')
|
||||
const run = await execFileAsync(process.execPath, [dshSdk, 'dev', 'index.ts'], {
|
||||
cwd: root,
|
||||
env: { ...commandEnvironment, DEEPSEEK_API_KEY: 'test-key' },
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
})
|
||||
expect(run.stderr).not.toContain('without inject')
|
||||
expect(await readFile(join(root, 'plugin-loaded'), 'utf8')).toBe('loaded\n')
|
||||
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as {
|
||||
dependencies: Record<string, string>
|
||||
}
|
||||
expect(manifest.dependencies.cordis).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/)
|
||||
expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
|
||||
}, 180_000)
|
||||
}
|
||||
})
|
||||
19
packages/scaffold/create-sdk/tsconfig.json
Normal file
19
packages/scaffold/create-sdk/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../helper"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/scaffold/create-sdk/tsdown.config.ts
Normal file
14
packages/scaffold/create-sdk/tsdown.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Bundle the library and create bin, then mirror package-owned terminal templates. */
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
|
||||
})
|
||||
6
packages/scaffold/helper/README.i18n.yaml
Normal file
6
packages/scaffold/helper/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/helper/README.md
|
||||
README.md: 416fcab9815e50ca662333eb6925cc37eb0c41c4
|
||||
README.zh.md: c50ec7aafa03377bd11759c50eeb2422a12fab68
|
||||
29
packages/scaffold/helper/README.md
Normal file
29
packages/scaffold/helper/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# `@deepseek-ai/dsh-helper`
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale.
|
||||
|
||||
The package owns the builtin typed-spec catalog, provider/app behavior entities, structured project file objects, helper-owned project templates, the shared typed `TextTemplate` renderer, package-manager strategies, local-plugin blueprints, typed questions, and the clack prompt adapter. It never boots a Cordis application.
|
||||
|
||||
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
|
||||
|
||||
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, and timeout policy. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to host compositions.
|
||||
|
||||
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`, but rejects a config that references the removed `@deepseek-ai/dsh-tui` root or a subpath. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
|
||||
|
||||
`.env.example` follows the currently selected features. `.env` is append-only: helper may add a missing differently named variable, but never updates or removes existing content.
|
||||
|
||||
The package root explicitly exports only the objects consumed by `create-sdk` and `dsh-scripts`; internal modules have no `src/*` or package-manifest subpath export.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the project domain edits files and never mounts a live agent or model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written.
|
||||
29
packages/scaffold/helper/README.zh.md
Normal file
29
packages/scaffold/helper/README.zh.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# `@deepseek-ai/dsh-helper`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
供 `create-sdk` 与 `dsh-sdk config` 共用的项目领域和基础设施。`SdkProject` 是只读快照;`ProjectEditSession` 是唯一的变更与提交边界。设计理由由 [SDK 架构 Agent Note(agent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) 负责。
|
||||
|
||||
该包(package)负责内置的类型化 spec 目录、提供方/应用行为实体、结构化项目文件对象、helper 自有项目模板、共享的类型化 `TextTemplate` 渲染器、包管理器策略、本地插件蓝图、类型化问题,以及 clack 交互提示适配器。它绝不会启动 Cordis 应用。
|
||||
|
||||
所有业务验证与文档验证都会在提交写入任何受影响文件前完成。提交会检测编辑会话打开后发生的外部修改,但在开始写入后,有意不提供跨文件回滚。
|
||||
|
||||
内置功能包括提供方、bash、app、持久化、HMR(热模块替换)、filesystem、todo、skill(技能)、web、subagent、工作流、压缩(compaction)、钩子、repeat-tool guard 和 timeout policy。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记;create 与 config 使用同一注册表和配置器。ACP(Agent Client Protocol)应用选项只贡献自动化桥;交互式服务属于宿主组合。
|
||||
|
||||
`SdkProject.open()` 只要求根目录下的 `package.json` 和 `cordis.yml` 可读,但会拒绝引用已移除的 `@deepseek-ai/dsh-tui` 包根或其子路径的配置。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。
|
||||
|
||||
`.env.example` 跟随当前所选功能。`.env` 仅追加:helper 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。
|
||||
|
||||
包根明确只导出 `create-sdk` 和 `dsh-scripts` 使用的对象;内部模块不提供 `src/*` 或 package-manifest 子路径导出。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。项目领域只编辑文件,绝不会挂载运行中的 agent(智能体),也不会发起模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;此包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **提交不具备跨文件事务性**:每次写入前都会检测外部修改,但后续失败不会回滚已经写入的文件。
|
||||
52
packages/scaffold/helper/package.json
Normal file
52
packages/scaffold/helper/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-helper",
|
||||
"description": "Domain model and infrastructure for creating and editing DeepSeek Harness SDK projects",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.4.3",
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subprocess": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-hooks-claude": "workspace:^",
|
||||
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
190
packages/scaffold/helper/src/documents/cordis-yaml-file.ts
Normal file
190
packages/scaffold/helper/src/documents/cordis-yaml-file.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Comment-preserving Cordis YAML document and `!!js` expression value.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/documents/cordis-yaml-file
|
||||
*/
|
||||
|
||||
import {
|
||||
Document, isMap, isSeq, parseDocument, visit, YAMLMap, YAMLSeq,
|
||||
type ScalarTag,
|
||||
} from 'yaml'
|
||||
import { ProjectFile, withTrailingNewline } from './project-file.ts'
|
||||
|
||||
/** Explicit JavaScript expression serialized with Cordis' `!!js` YAML tag. */
|
||||
export class JsExpression {
|
||||
/** Expression source evaluated by the Cordis include loader. */
|
||||
readonly source: string
|
||||
|
||||
/** Create an expression value. */
|
||||
constructor(source: string) {
|
||||
if (source.trim().length === 0) throw new Error('JavaScript expression must not be empty')
|
||||
this.source = source
|
||||
}
|
||||
|
||||
/** Return expression source for YAML scalar stringification. */
|
||||
toString(): string {
|
||||
return this.source
|
||||
}
|
||||
}
|
||||
|
||||
const JS_EXPRESSION_TAG: ScalarTag = {
|
||||
tag: 'tag:yaml.org,2002:js',
|
||||
identify: value => value instanceof JsExpression,
|
||||
resolve: value => new JsExpression(value),
|
||||
stringify: item => String(item.value),
|
||||
}
|
||||
|
||||
/** Plain domain representation of one top-level Cordis config entry. */
|
||||
export interface CordisConfigEntry {
|
||||
id: string
|
||||
name: string
|
||||
config?: Record<string, unknown>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function parseYaml(text: string): Document.Parsed {
|
||||
const document = parseDocument(text, {
|
||||
customTags: [JS_EXPRESSION_TAG],
|
||||
keepSourceTokens: true,
|
||||
prettyErrors: true,
|
||||
})
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`invalid cordis.yml: ${document.errors.map(error => error.message).join('; ')}`)
|
||||
}
|
||||
if (!isSeq(document.contents)) throw new Error('invalid cordis.yml: root must be a sequence')
|
||||
visit(document, { Collection: (_key, collection) => { collection.flow = false } })
|
||||
return document
|
||||
}
|
||||
|
||||
function entryFromValue(value: unknown): CordisConfigEntry {
|
||||
/* v8 ignore next -- entries() calls this only after requiring a YAMLMap, whose JSON value is an object */
|
||||
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
||||
throw new Error('invalid cordis.yml entry: expected an object')
|
||||
}
|
||||
const entry = value as Record<string, unknown>
|
||||
if (typeof entry.id !== 'string' || entry.id.length === 0) {
|
||||
throw new Error('invalid cordis.yml entry: id must be a non-empty string')
|
||||
}
|
||||
if (typeof entry.name !== 'string' || entry.name.length === 0) {
|
||||
throw new Error(`invalid cordis.yml entry ${entry.id}: name must be a non-empty string`)
|
||||
}
|
||||
if (entry.config !== undefined
|
||||
&& (entry.config === null || Array.isArray(entry.config) || typeof entry.config !== 'object')) {
|
||||
throw new Error(`invalid cordis.yml entry ${entry.id}: plugin config must be an object`)
|
||||
}
|
||||
if (entry.disabled !== undefined && typeof entry.disabled !== 'boolean') {
|
||||
throw new Error(`invalid cordis.yml entry ${entry.id}: disabled must be boolean`)
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
...entry.config !== undefined ? { config: entry.config as Record<string, unknown> } : {},
|
||||
...entry.disabled !== undefined ? { disabled: entry.disabled } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Editable top-level cordis.yml using YAML's document API. */
|
||||
export class CordisYamlFile extends ProjectFile {
|
||||
private readonly document: Document.Parsed
|
||||
|
||||
private constructor(document: Document.Parsed, originalText?: string) {
|
||||
super('cordis.yml', originalText)
|
||||
this.document = document
|
||||
}
|
||||
|
||||
/** Create an empty Cordis config entry list. */
|
||||
static create(): CordisYamlFile {
|
||||
return new CordisYamlFile(parseYaml('[]\n'))
|
||||
}
|
||||
|
||||
/** Parse an existing cordis.yml while retaining comments and scalar styles. */
|
||||
static parse(text: string): CordisYamlFile {
|
||||
return new CordisYamlFile(parseYaml(text), text)
|
||||
}
|
||||
|
||||
/** Clone through YAML text so the edit session owns an independent AST. */
|
||||
override clone(): CordisYamlFile {
|
||||
return new CordisYamlFile(parseYaml(this.serialize()), this.originalText)
|
||||
}
|
||||
|
||||
private sequence(): YAMLSeq {
|
||||
/* v8 ignore next -- parseYaml and create both establish a sequence root */
|
||||
if (!isSeq(this.document.contents)) throw new Error('cordis.yml root is not a sequence')
|
||||
return this.document.contents
|
||||
}
|
||||
|
||||
private entryNode(id: string): YAMLMap | undefined {
|
||||
for (const item of this.sequence().items) {
|
||||
if (!isMap(item)) continue
|
||||
if (item.get('id') === id) return item
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Return defensive plain entry values in file order. */
|
||||
entries(): CordisConfigEntry[] {
|
||||
return this.sequence().items.map((item) => {
|
||||
if (!isMap(item)) throw new Error('invalid cordis.yml: every entry must be a mapping')
|
||||
return entryFromValue(item.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
/** Find one entry by stable id. */
|
||||
entry(id: string): CordisConfigEntry | undefined {
|
||||
return this.entries().find(entry => entry.id === id)
|
||||
}
|
||||
|
||||
/** Add one new top-level entry, rejecting duplicate ids. */
|
||||
addEntry(entry: CordisConfigEntry, commentedExample?: string): void {
|
||||
if (this.entryNode(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`)
|
||||
const node = this.document.createNode(entry)
|
||||
if (commentedExample) node.comment = commentedExample.split('\n').map(line => ` ${line}`).join('\n')
|
||||
this.sequence().items.push(node)
|
||||
}
|
||||
|
||||
/** Remove an entry by id and report whether it existed. */
|
||||
removeEntry(id: string): boolean {
|
||||
const sequence = this.sequence()
|
||||
const index = sequence.items.findIndex(item => isMap(item) && item.get('id') === id)
|
||||
if (index < 0) return false
|
||||
sequence.items.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Enable or disable an entry through the Loader-native field. */
|
||||
setDisabled(id: string, disabled: boolean): void {
|
||||
const node = this.entryNode(id)
|
||||
if (!node) throw new Error(`Cordis config entry does not exist: ${id}`)
|
||||
if (disabled) node.set('disabled', true)
|
||||
else node.delete('disabled')
|
||||
}
|
||||
|
||||
/** Replace only owned plugin config keys while retaining unknown user keys. */
|
||||
updateOwnedConfig(id: string, ownedKeys: readonly string[], next: Record<string, unknown>): void {
|
||||
const entry = this.entryNode(id)
|
||||
if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`)
|
||||
let config: unknown = entry.get('config', true)
|
||||
if (config === undefined || config === null) {
|
||||
config = new YAMLMap()
|
||||
entry.set('config', config)
|
||||
}
|
||||
if (!isMap(config)) throw new Error(`Cordis config entry ${id} plugin config is not a mapping`)
|
||||
for (const key of ownedKeys) config.delete(key)
|
||||
for (const [key, value] of Object.entries(next)) config.set(key, this.document.createNode(value))
|
||||
if (config.items.length === 0) entry.delete('config')
|
||||
}
|
||||
|
||||
/** Validate ids, names, plugin config maps, and id uniqueness. */
|
||||
override validate(): void {
|
||||
const seen = new Set<string>()
|
||||
for (const entry of this.entries()) {
|
||||
if (seen.has(entry.id)) throw new Error(`duplicate Cordis config entry id: ${entry.id}`)
|
||||
seen.add(entry.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize through the YAML document while retaining untouched trivia. */
|
||||
override serialize(): string {
|
||||
return withTrailingNewline(this.document.toString({ lineWidth: 0 }))
|
||||
}
|
||||
}
|
||||
111
packages/scaffold/helper/src/documents/env-file.ts
Normal file
111
packages/scaffold/helper/src/documents/env-file.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Ownership-aware, line-preserving dotenv document.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/documents/env-file
|
||||
*/
|
||||
|
||||
import { ProjectFile, withTrailingNewline } from './project-file.ts'
|
||||
|
||||
interface ParsedVariable {
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const VARIABLE = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
|
||||
const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
/** `.env` appends missing variables; `.env.example` supports managed replacement and removal. */
|
||||
export class EnvFile extends ProjectFile {
|
||||
private readonly lines: string[]
|
||||
|
||||
private constructor(relativePath: '.env' | '.env.example', lines: string[], originalText?: string) {
|
||||
super(relativePath, originalText, relativePath === '.env' ? 0o600 : undefined)
|
||||
this.lines = [...lines]
|
||||
}
|
||||
|
||||
/** Create an empty environment file. */
|
||||
static create(relativePath: '.env' | '.env.example'): EnvFile {
|
||||
return new EnvFile(relativePath, [])
|
||||
}
|
||||
|
||||
/** Parse an existing environment file without rewriting unknown lines. */
|
||||
static parse(relativePath: '.env' | '.env.example', text: string): EnvFile {
|
||||
const normalized = text.replace(/\n$/, '')
|
||||
return new EnvFile(relativePath, normalized.length === 0 ? [] : normalized.split('\n'), text)
|
||||
}
|
||||
|
||||
/** Clone the current line model. */
|
||||
override clone(): EnvFile {
|
||||
return new EnvFile(this.relativePath as '.env' | '.env.example', this.lines, this.originalText)
|
||||
}
|
||||
|
||||
private variables(): Map<string, ParsedVariable[]> {
|
||||
const values = new Map<string, ParsedVariable[]>()
|
||||
this.lines.forEach((line, index) => {
|
||||
const match = VARIABLE.exec(line)
|
||||
if (!match) return
|
||||
const name = match[1]
|
||||
const value = match[2]
|
||||
/* v8 ignore next -- both captures are mandatory in VARIABLE */
|
||||
if (name === undefined || value === undefined) return
|
||||
const occurrences = values.get(name) ?? []
|
||||
occurrences.push({ index, value })
|
||||
values.set(name, occurrences)
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
/** Read the effective value; append-only `.env` accepts duplicates and uses the last declaration. */
|
||||
get(name: string): string | undefined {
|
||||
const occurrences = this.variables().get(name) ?? []
|
||||
if (this.relativePath === '.env.example' && occurrences.length > 1) {
|
||||
throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
|
||||
}
|
||||
return occurrences.at(-1)?.value
|
||||
}
|
||||
|
||||
/** Add or replace one SDK-managed `.env.example` variable while preserving unrelated lines. */
|
||||
set(name: string, value: string): void {
|
||||
if (this.relativePath !== '.env.example') throw new Error('.env is append-only')
|
||||
if (!VARIABLE_NAME.test(name)) throw new Error(`invalid environment variable name: ${name}`)
|
||||
const occurrences = this.variables().get(name) ?? []
|
||||
if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
|
||||
const line = `${name}=${value}`
|
||||
if (occurrences[0]) this.lines[occurrences[0].index] = line
|
||||
else this.lines.push(line)
|
||||
}
|
||||
|
||||
/** Append a missing `.env` variable and optional comment without changing any existing declaration. */
|
||||
append(name: string, value: string, comment?: string): boolean {
|
||||
if (this.relativePath !== '.env') throw new Error('.env.example is SDK-managed')
|
||||
if (!VARIABLE_NAME.test(name)) throw new Error(`invalid environment variable name: ${name}`)
|
||||
if (comment !== undefined && (!comment || comment.includes('\n'))) {
|
||||
throw new Error('environment comment must be one non-empty line')
|
||||
}
|
||||
if (this.variables().has(name)) return false
|
||||
if (comment) this.lines.push(`# ${comment}`)
|
||||
this.lines.push(`${name}=${value}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove one SDK-managed `.env.example` variable while retaining every other line. */
|
||||
remove(name: string): void {
|
||||
if (this.relativePath !== '.env.example') throw new Error('.env is append-only')
|
||||
const occurrences = this.variables().get(name) ?? []
|
||||
if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
|
||||
if (occurrences[0]) this.lines.splice(occurrences[0].index, 1)
|
||||
}
|
||||
|
||||
/** Validate the managed placeholder file; append-only `.env` accepts duplicate declarations. */
|
||||
override validate(): void {
|
||||
if (this.relativePath === '.env') return
|
||||
for (const [name, occurrences] of this.variables()) {
|
||||
if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize all retained lines with one trailing newline. */
|
||||
override serialize(): string {
|
||||
return withTrailingNewline(this.lines.join('\n'))
|
||||
}
|
||||
}
|
||||
169
packages/scaffold/helper/src/documents/package-json-file.ts
Normal file
169
packages/scaffold/helper/src/documents/package-json-file.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Structured package.json document owned by an SDK project.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/documents/package-json-file
|
||||
*/
|
||||
|
||||
import { ProjectFile, withTrailingNewline } from './project-file.ts'
|
||||
|
||||
/** NPM dependency sections managed by the SDK. */
|
||||
export type NpmDependencySection = 'dependencies' | 'devDependencies'
|
||||
|
||||
/** JSON shape retained by {@link PackageJsonFile}. */
|
||||
export interface PackageManifest {
|
||||
name?: string
|
||||
version?: string
|
||||
private?: boolean
|
||||
description?: string
|
||||
type?: string
|
||||
packageManager?: string
|
||||
scripts?: Record<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
workspaces?: string[]
|
||||
resolutions?: Record<string, string>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function parseManifest(text: string): PackageManifest {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new Error(`invalid package.json: ${String(error)}`)
|
||||
}
|
||||
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
||||
throw new Error('invalid package.json: root must be an object')
|
||||
}
|
||||
return value as PackageManifest
|
||||
}
|
||||
|
||||
function sortedRecord(value: Record<string, string>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)))
|
||||
}
|
||||
|
||||
/** Editable, deterministic package.json representation. */
|
||||
export class PackageJsonFile extends ProjectFile {
|
||||
private readonly manifest: PackageManifest
|
||||
|
||||
private constructor(manifest: PackageManifest, originalText?: string) {
|
||||
super('package.json', originalText)
|
||||
this.manifest = structuredClone(manifest)
|
||||
}
|
||||
|
||||
/** Create a new package manifest from a complete rendered template. */
|
||||
static create(text: string): PackageJsonFile {
|
||||
return new PackageJsonFile(parseManifest(text))
|
||||
}
|
||||
|
||||
/** Parse an existing package.json document. */
|
||||
static parse(text: string): PackageJsonFile {
|
||||
return new PackageJsonFile(parseManifest(text), text)
|
||||
}
|
||||
|
||||
/** Clone this document and its nested manifest data. */
|
||||
override clone(): PackageJsonFile {
|
||||
return new PackageJsonFile(this.manifest, this.originalText)
|
||||
}
|
||||
|
||||
/** Return a defensive copy of the manifest. */
|
||||
value(): Readonly<PackageManifest> {
|
||||
return structuredClone(this.manifest)
|
||||
}
|
||||
|
||||
/** Set one package script. */
|
||||
setScript(name: string, command: string): void {
|
||||
this.manifest.scripts ??= {}
|
||||
this.manifest.scripts[name] = command
|
||||
}
|
||||
|
||||
/** Read one package script. */
|
||||
script(name: string): string | undefined {
|
||||
return this.manifest.scripts?.[name]
|
||||
}
|
||||
|
||||
/** Remove one package script. */
|
||||
removeScript(name: string): void {
|
||||
delete this.manifest.scripts?.[name]
|
||||
}
|
||||
|
||||
/** Set one NPM dependency in its runtime or development section. */
|
||||
setNpmDependency(section: NpmDependencySection, name: string, spec: string): void {
|
||||
this.manifest[section] ??= {}
|
||||
this.manifest[section][name] = spec
|
||||
}
|
||||
|
||||
/** Remove one NPM dependency from a section. */
|
||||
removeNpmDependency(section: NpmDependencySection, name: string): void {
|
||||
delete this.manifest[section]?.[name]
|
||||
}
|
||||
|
||||
/** Read an NPM dependency spec from either managed section. */
|
||||
npmDependency(name: string): { section: NpmDependencySection; spec: string } | undefined {
|
||||
for (const section of ['dependencies', 'devDependencies'] as const) {
|
||||
const spec = this.manifest[section]?.[name]
|
||||
if (spec !== undefined) return { section, spec }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Return all managed NPM dependency names. */
|
||||
npmDependencyNames(): string[] {
|
||||
return [...new Set([
|
||||
...Object.keys(this.manifest.dependencies ?? {}),
|
||||
...Object.keys(this.manifest.devDependencies ?? {}),
|
||||
])].sort()
|
||||
}
|
||||
|
||||
/** Add a package-manager workspace glob. */
|
||||
addWorkspace(pattern: string): void {
|
||||
const workspaces = this.manifest.workspaces ??= []
|
||||
if (!workspaces.includes(pattern)) workspaces.push(pattern)
|
||||
}
|
||||
|
||||
/** Set or remove the packageManager field. */
|
||||
setPackageManager(value: string | undefined): void {
|
||||
if (value === undefined) delete this.manifest.packageManager
|
||||
else this.manifest.packageManager = value
|
||||
}
|
||||
|
||||
/** Pin a Yarn resolution used by live-link projects. */
|
||||
setResolution(name: string, spec: string): void {
|
||||
this.manifest.resolutions ??= {}
|
||||
this.manifest.resolutions[name] = spec
|
||||
}
|
||||
|
||||
/** Validate the fields the SDK relies on. */
|
||||
override validate(): void {
|
||||
if (!this.manifest.name || typeof this.manifest.name !== 'string') {
|
||||
throw new Error('package.json name must be a non-empty string')
|
||||
}
|
||||
for (const section of ['scripts', 'dependencies', 'devDependencies'] as const) {
|
||||
const value: unknown = this.manifest[section]
|
||||
if (value === undefined) continue
|
||||
if (value === null || Array.isArray(value) || typeof value !== 'object') {
|
||||
throw new Error(`package.json ${section} must be an object`)
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (typeof item !== 'string' || item.length === 0) {
|
||||
throw new Error(`package.json ${section}.${key} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.manifest.workspaces !== undefined
|
||||
&& (!Array.isArray(this.manifest.workspaces) || this.manifest.workspaces.some(item => typeof item !== 'string'))) {
|
||||
throw new Error('package.json workspaces must be an array of strings')
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize with deterministic managed maps and two-space JSON formatting. */
|
||||
override serialize(): string {
|
||||
const value: PackageManifest = structuredClone(this.manifest)
|
||||
if (this.manifest.scripts) value.scripts = sortedRecord(this.manifest.scripts)
|
||||
if (this.manifest.dependencies) value.dependencies = sortedRecord(this.manifest.dependencies)
|
||||
if (this.manifest.devDependencies) value.devDependencies = sortedRecord(this.manifest.devDependencies)
|
||||
if (this.manifest.workspaces) value.workspaces = [...this.manifest.workspaces].sort()
|
||||
if (this.manifest.resolutions) value.resolutions = sortedRecord(this.manifest.resolutions)
|
||||
return withTrailingNewline(JSON.stringify(value, null, 2))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Structured pnpm workspace configuration for generated SDK projects.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/documents/pnpm-workspace-file
|
||||
*/
|
||||
|
||||
import {
|
||||
isMap, isScalar, isSeq, parseDocument,
|
||||
type Document, type Scalar, type YAMLMap, type YAMLSeq,
|
||||
} from 'yaml'
|
||||
import { ProjectFile, withTrailingNewline } from './project-file.ts'
|
||||
|
||||
function parseYaml(text: string): Document.Parsed {
|
||||
const document = parseDocument(text, { keepSourceTokens: true, prettyErrors: true })
|
||||
if (document.errors.length > 0) {
|
||||
throw new Error(`invalid pnpm-workspace.yaml: ${document.errors.map(error => error.message).join('; ')}`)
|
||||
}
|
||||
if (!isMap(document.contents)) throw new Error('pnpm-workspace.yaml root must be an object')
|
||||
return document
|
||||
}
|
||||
|
||||
/** Generated pnpm-workspace.yaml model. */
|
||||
export class PnpmWorkspaceFile extends ProjectFile {
|
||||
private readonly document: Document.Parsed
|
||||
|
||||
private constructor(document: Document.Parsed, originalText?: string) {
|
||||
super('pnpm-workspace.yaml', originalText)
|
||||
this.document = document
|
||||
}
|
||||
|
||||
/** Create a pnpm workspace document. */
|
||||
static create(): PnpmWorkspaceFile {
|
||||
const document = new PnpmWorkspaceFile(parseYaml('{}\n'))
|
||||
document.mapping().set('packages', document.document.createNode([]))
|
||||
document.mapping().set('allowBuilds', document.document.createNode({ esbuild: true }))
|
||||
return document
|
||||
}
|
||||
|
||||
/** Parse the workspace fields the SDK owns while retaining all other YAML. */
|
||||
static parse(text: string): PnpmWorkspaceFile {
|
||||
const document = new PnpmWorkspaceFile(parseYaml(text), text)
|
||||
document.packageSequence()
|
||||
const autoInstallPeers = document.mapping().get('autoInstallPeers')
|
||||
if (autoInstallPeers !== undefined && typeof autoInstallPeers !== 'boolean') {
|
||||
throw new Error('pnpm-workspace.yaml autoInstallPeers must be boolean')
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
/** Clone the complete comment-preserving workspace document. */
|
||||
override clone(): PnpmWorkspaceFile {
|
||||
return new PnpmWorkspaceFile(parseYaml(this.serialize()), this.originalText)
|
||||
}
|
||||
|
||||
/** Add one package workspace glob. */
|
||||
addPackage(pattern: string): void {
|
||||
const packages = this.packageSequence()
|
||||
if (packages.items.some(item => item.value === pattern)) return
|
||||
packages.add(this.document.createNode(pattern))
|
||||
}
|
||||
|
||||
/** Disable registry peer auto-installation for live-link projects. */
|
||||
disableAutoInstallPeers(): void {
|
||||
this.mapping().set('autoInstallPeers', false)
|
||||
}
|
||||
|
||||
/** Validate workspace globs. */
|
||||
override validate(): void {
|
||||
for (const pattern of this.packageValues()) {
|
||||
if (pattern.trim().length === 0) throw new Error('pnpm workspace pattern must not be empty')
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize the workspace while retaining unknown settings and comments. */
|
||||
override serialize(): string {
|
||||
return withTrailingNewline(this.document.toString({ lineWidth: 0 }))
|
||||
}
|
||||
|
||||
private mapping(): YAMLMap {
|
||||
/* v8 ignore next -- parseYaml and create both establish a mapping root */
|
||||
if (!isMap(this.document.contents)) throw new Error('pnpm-workspace.yaml root must be an object')
|
||||
return this.document.contents
|
||||
}
|
||||
|
||||
private packageSequence(): YAMLSeq<Scalar<string>> {
|
||||
const packages = this.mapping().get('packages', true)
|
||||
if (!isSeq(packages) || packages.items.some(item => !isScalar(item) || typeof item.value !== 'string')) {
|
||||
throw new Error('pnpm-workspace.yaml packages must be an array of strings')
|
||||
}
|
||||
return packages as YAMLSeq<Scalar<string>>
|
||||
}
|
||||
|
||||
private packageValues(): string[] {
|
||||
return this.packageSequence().items.map(item => item.value)
|
||||
}
|
||||
}
|
||||
64
packages/scaffold/helper/src/documents/project-file.ts
Normal file
64
packages/scaffold/helper/src/documents/project-file.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Base abstraction for one file in an SDK project snapshot.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/documents/project-file
|
||||
*/
|
||||
|
||||
/** Return text with exactly one trailing newline. */
|
||||
export function withTrailingNewline(text: string): string {
|
||||
return text.replace(/\n*$/, '') + '\n'
|
||||
}
|
||||
|
||||
/** One cloneable, validatable project file. */
|
||||
export abstract class ProjectFile {
|
||||
/** Project-relative POSIX path. */
|
||||
readonly relativePath: string
|
||||
|
||||
/** Text observed when the document entered the snapshot; absent for a new file. */
|
||||
readonly originalText: string | undefined
|
||||
|
||||
/** Permission bits used only when the file is first created. */
|
||||
readonly createMode: number | undefined
|
||||
|
||||
protected constructor(relativePath: string, originalText?: string, createMode?: number) {
|
||||
if (relativePath.startsWith('/') || relativePath.split('/').includes('..')) {
|
||||
throw new Error(`project document path must stay inside the project: ${relativePath}`)
|
||||
}
|
||||
this.relativePath = relativePath
|
||||
this.originalText = originalText
|
||||
this.createMode = createMode
|
||||
}
|
||||
|
||||
/** Clone the document for an isolated edit session. */
|
||||
abstract clone(): ProjectFile
|
||||
|
||||
/** Validate the document's complete current state. */
|
||||
abstract validate(): void
|
||||
|
||||
/** Serialize the complete current file. */
|
||||
abstract serialize(): string
|
||||
}
|
||||
|
||||
/** Immutable complete-text file used by one-shot artifacts. */
|
||||
export class TextProjectFile extends ProjectFile {
|
||||
private readonly text: string
|
||||
|
||||
/** Create a complete-text project document. */
|
||||
constructor(relativePath: string, text: string, originalText?: string) {
|
||||
super(relativePath, originalText)
|
||||
this.text = withTrailingNewline(text)
|
||||
}
|
||||
|
||||
/** Clone this immutable document. */
|
||||
override clone(): TextProjectFile {
|
||||
return new TextProjectFile(this.relativePath, this.text, this.originalText)
|
||||
}
|
||||
|
||||
/** Complete text artifacts have no extra structural validation. */
|
||||
override validate(): void {}
|
||||
|
||||
/** Return the complete artifact text. */
|
||||
override serialize(): string {
|
||||
return this.text
|
||||
}
|
||||
}
|
||||
89
packages/scaffold/helper/src/documents/tsconfig-file.ts
Normal file
89
packages/scaffold/helper/src/documents/tsconfig-file.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Comment-preserving root tsconfig editor for local plugin references.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/documents/tsconfig-file
|
||||
*/
|
||||
|
||||
import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'
|
||||
import { ProjectFile, withTrailingNewline } from './project-file.ts'
|
||||
|
||||
const FORMAT = { insertSpaces: true, tabSize: 2, eol: '\n' }
|
||||
|
||||
function parseConfig(text: string): Record<string, unknown> {
|
||||
const errors: ParseError[] = []
|
||||
const value: unknown = parse(text, errors, { allowTrailingComma: true, disallowComments: false })
|
||||
if (errors.length > 0 || value === null || Array.isArray(value) || typeof value !== 'object') {
|
||||
throw new Error('tsconfig.json is not a valid JSONC object')
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Root tsconfig document edited with jsonc-parser patches. */
|
||||
export class TsConfigFile extends ProjectFile {
|
||||
private text: string
|
||||
|
||||
private constructor(text: string, originalText?: string) {
|
||||
super('tsconfig.json', originalText)
|
||||
this.text = withTrailingNewline(text)
|
||||
}
|
||||
|
||||
/** Create the root project-reference config. */
|
||||
static create(): TsConfigFile {
|
||||
return new TsConfigFile(JSON.stringify({
|
||||
extends: './tsconfig.base.json',
|
||||
compilerOptions: { noEmit: true },
|
||||
include: ['index.ts'],
|
||||
references: [],
|
||||
}, null, 2))
|
||||
}
|
||||
|
||||
/** Parse an existing root tsconfig. */
|
||||
static parse(text: string): TsConfigFile {
|
||||
parseConfig(text)
|
||||
return new TsConfigFile(text, text)
|
||||
}
|
||||
|
||||
/** Clone the current JSONC text. */
|
||||
override clone(): TsConfigFile {
|
||||
return new TsConfigFile(this.text, this.originalText)
|
||||
}
|
||||
|
||||
/** Add one project reference while retaining comments and formatting. */
|
||||
addReference(path: string): void {
|
||||
const value = parseConfig(this.text)
|
||||
const references = value.references
|
||||
if (references !== undefined && !Array.isArray(references)) {
|
||||
throw new Error('tsconfig.json references must be an array')
|
||||
}
|
||||
const typed = (references ?? []) as unknown[]
|
||||
for (const item of typed) {
|
||||
if (item === null || Array.isArray(item) || typeof item !== 'object' || typeof (item as { path?: unknown }).path !== 'string') {
|
||||
throw new Error('tsconfig.json references must contain { path: string } objects')
|
||||
}
|
||||
}
|
||||
if (typed.some(item => (item as { path: string }).path === path)) return
|
||||
this.text = applyEdits(this.text, modify(
|
||||
this.text,
|
||||
['references', typed.length],
|
||||
{ path },
|
||||
{ formattingOptions: FORMAT, isArrayInsertion: true },
|
||||
))
|
||||
}
|
||||
|
||||
/** Validate JSONC and the project-reference shape. */
|
||||
override validate(): void {
|
||||
const value = parseConfig(this.text)
|
||||
if (value.references === undefined) return
|
||||
if (!Array.isArray(value.references)) throw new Error('tsconfig.json references must be an array')
|
||||
for (const item of value.references) {
|
||||
if (item === null || Array.isArray(item) || typeof item !== 'object' || typeof (item as { path?: unknown }).path !== 'string') {
|
||||
throw new Error('tsconfig.json references must contain { path: string } objects')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return patched JSONC text. */
|
||||
override serialize(): string {
|
||||
return withTrailingNewline(this.text)
|
||||
}
|
||||
}
|
||||
100
packages/scaffold/helper/src/features/builtin/app.ts
Normal file
100
packages/scaffold/helper/src/features/builtin/app.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Required run-interface app feature.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/app
|
||||
*/
|
||||
|
||||
import { featureId } from '../../ids.ts'
|
||||
import type { ProjectProfile, RunInterface } from '../../project/types.ts'
|
||||
import {
|
||||
createAppPackageScripts,
|
||||
createAppProjectArtifacts,
|
||||
createProjectTemplateContext,
|
||||
} from '../../templates/project-template.ts'
|
||||
import {
|
||||
FeatureOption,
|
||||
ExclusiveOptionFeature,
|
||||
} from '../feature.ts'
|
||||
import { ProjectContribution, type ProjectResource } from '../resources.ts'
|
||||
import {
|
||||
npmCordisConfigEntry,
|
||||
ownedTextFile,
|
||||
packageScript,
|
||||
requiredString,
|
||||
} from './helpers.ts'
|
||||
|
||||
const ID = featureId('app')
|
||||
|
||||
function appProjectResources(
|
||||
profile: ProjectProfile,
|
||||
runInterface: RunInterface,
|
||||
): readonly ProjectResource[] {
|
||||
const context = createProjectTemplateContext(profile, runInterface)
|
||||
const scripts = createAppPackageScripts()
|
||||
return [
|
||||
...createAppProjectArtifacts(context).map(document => (
|
||||
ownedTextFile(ID, document.relativePath, document.serialize())
|
||||
)),
|
||||
packageScript(ID, 'dev', scripts.dev),
|
||||
packageScript(ID, 'start', scripts.start),
|
||||
]
|
||||
}
|
||||
|
||||
class AppOption extends FeatureOption {
|
||||
override readonly id: RunInterface
|
||||
override readonly label: string
|
||||
|
||||
constructor(id: RunInterface, label: string) {
|
||||
super()
|
||||
this.id = id
|
||||
this.label = label
|
||||
}
|
||||
|
||||
/** Identify options by their unique front door, not the shared interaction service. */
|
||||
override markerConfigEntries(): readonly { id: string; name: string }[] {
|
||||
switch (this.id) {
|
||||
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
|
||||
case 'embed': return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Embed is identified by the configured loop with no external front door. */
|
||||
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
|
||||
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
|
||||
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
|
||||
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')
|
||||
}
|
||||
|
||||
override contribution(profile: ProjectProfile): ProjectContribution {
|
||||
switch (this.id) {
|
||||
case 'acp':
|
||||
return new ProjectContribution([
|
||||
...appProjectResources(profile, this.id),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'acp',
|
||||
name: '@deepseek-ai/dsh-acp',
|
||||
config: { model: profile.runtime.model },
|
||||
}, ['model'], config => requiredString(config, 'model')),
|
||||
])
|
||||
case 'embed':
|
||||
return new ProjectContribution(appProjectResources(profile, this.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Required app selection represented by ACP or embed options. */
|
||||
export class AppFeature extends ExclusiveOptionFeature {
|
||||
override readonly id = ID
|
||||
override readonly summary = 'Run interface'
|
||||
override readonly required = true
|
||||
override readonly requires = [featureId('spine')]
|
||||
override readonly options = [
|
||||
new AppOption('acp', 'ACP automation server'),
|
||||
new AppOption('embed', 'Embedded context'),
|
||||
]
|
||||
|
||||
/** Default to the profile's already selected front door. */
|
||||
override defaultOptions(profile: ProjectProfile): readonly string[] {
|
||||
return [profile.runInterface]
|
||||
}
|
||||
}
|
||||
122
packages/scaffold/helper/src/features/builtin/helpers.ts
Normal file
122
packages/scaffold/helper/src/features/builtin/helpers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Small resource constructors shared by builtin feature modules.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/helpers
|
||||
*/
|
||||
|
||||
import type { CordisConfigEntry } from '../../documents/cordis-yaml-file.ts'
|
||||
import { TextProjectFile } from '../../documents/project-file.ts'
|
||||
import { resourceKey } from '../../ids.ts'
|
||||
import type {
|
||||
CordisConfigEntryResource,
|
||||
EnvironmentResource,
|
||||
OwnedFileResource,
|
||||
NpmDependencyResource,
|
||||
PackageScriptResource,
|
||||
} from '../resources.ts'
|
||||
|
||||
/** Return the installable package name for a bare package or package subpath. */
|
||||
function installablePackageName(specifier: string): string {
|
||||
const segments = specifier.split('/')
|
||||
const expectedSegments = specifier.startsWith('@') ? 2 : 1
|
||||
if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) {
|
||||
throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`)
|
||||
}
|
||||
return segments.slice(0, expectedSegments).join('/')
|
||||
}
|
||||
|
||||
/** Create a runtime NPM dependency resource. */
|
||||
function npmDependency(_owner: string, specifier: string): NpmDependencyResource {
|
||||
const name = installablePackageName(specifier)
|
||||
return {
|
||||
kind: 'npm-dependency',
|
||||
key: resourceKey(`npm-dependency:${name}`),
|
||||
name,
|
||||
section: 'dependencies',
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a feature-owned package script that is replaceable only while unchanged. */
|
||||
export function packageScript(_owner: string, name: string, command: string): PackageScriptResource {
|
||||
return {
|
||||
kind: 'package-script',
|
||||
key: resourceKey(`package-script:${name}`),
|
||||
name,
|
||||
command,
|
||||
removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a Cordis config entry resource with explicitly owned config keys. */
|
||||
export function cordisConfigEntry(
|
||||
_owner: string,
|
||||
value: CordisConfigEntry,
|
||||
ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}),
|
||||
validateConfig?: CordisConfigEntryResource['validateConfig'],
|
||||
): CordisConfigEntryResource {
|
||||
return {
|
||||
kind: 'cordis-config-entry',
|
||||
key: resourceKey(`cordis-config-entry:${value.id}`),
|
||||
entry: value,
|
||||
ownedConfigKeys,
|
||||
...validateConfig ? { validateConfig } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */
|
||||
export function npmCordisConfigEntry(
|
||||
owner: string,
|
||||
value: CordisConfigEntry,
|
||||
ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}),
|
||||
validateConfig?: CordisConfigEntryResource['validateConfig'],
|
||||
): readonly [NpmDependencyResource, CordisConfigEntryResource] {
|
||||
return [
|
||||
npmDependency(owner, value.name),
|
||||
cordisConfigEntry(owner, value, ownedConfigKeys, validateConfig),
|
||||
]
|
||||
}
|
||||
|
||||
/** Create a secret/environment binding resource. */
|
||||
export function environment(
|
||||
_owner: string,
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
comment?: string,
|
||||
): EnvironmentResource {
|
||||
return {
|
||||
kind: 'environment',
|
||||
key: resourceKey(`environment:${name}`),
|
||||
name,
|
||||
...value === undefined ? {} : { value },
|
||||
exampleValue: '',
|
||||
...comment === undefined ? {} : { comment },
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an owned complete-text file that is removable only while unchanged. */
|
||||
export function ownedTextFile(_owner: string, path: string, text: string): OwnedFileResource {
|
||||
return {
|
||||
kind: 'owned-file',
|
||||
key: resourceKey(`file:${path}`),
|
||||
document: new TextProjectFile(path, text),
|
||||
removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a config key as a string when present. */
|
||||
export function optionalString(config: Readonly<Record<string, unknown>>, key: string): string[] {
|
||||
return config[key] === undefined || typeof config[key] === 'string' ? [] : [`${key} must be a string`]
|
||||
}
|
||||
|
||||
/** Validate a config key as a non-empty string when required. */
|
||||
export function requiredString(config: Readonly<Record<string, unknown>>, key: string): string[] {
|
||||
return typeof config[key] === 'string' && config[key].length > 0 ? [] : [`${key} must be a non-empty string`]
|
||||
}
|
||||
|
||||
/** Validate a config key as an array of strings. */
|
||||
export function stringArray(config: Readonly<Record<string, unknown>>, key: string): string[] {
|
||||
const value = config[key]
|
||||
return Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||
? []
|
||||
: [`${key} must be an array of strings`]
|
||||
}
|
||||
368
packages/scaffold/helper/src/features/builtin/index.ts
Normal file
368
packages/scaffold/helper/src/features/builtin/index.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Ordered builtin feature catalog: behavior entities only where project
|
||||
* context changes the contribution, typed specs everywhere else.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin
|
||||
*/
|
||||
|
||||
import type { Config as ClaudeHooksConfig } from '@deepseek-ai/dsh-hooks-claude'
|
||||
import type { Config as CodexHooksConfig } from '@deepseek-ai/dsh-hooks-codex'
|
||||
import type { Config as JsonlConfig } from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import type { Config as SqliteConfig } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||
import type { Config as ToolSubagentConfig } from '@deepseek-ai/dsh-tool-subagent'
|
||||
import type { Config as ToolTodoConfig } from '@deepseek-ai/dsh-tool-todo'
|
||||
import type { Config as ToolWebConfig } from '@deepseek-ai/dsh-tool-web'
|
||||
import type { ProjectProfile } from '../../project/types.ts'
|
||||
import { defineFeatures } from '../define-feature.ts'
|
||||
import { FeatureRegistry } from '../registry.ts'
|
||||
import { AppFeature } from './app.ts'
|
||||
import { ProviderFeature } from './provider.ts'
|
||||
import { SpineFeature } from './spine.ts'
|
||||
|
||||
/**
|
||||
* Build and definition-check the complete builtin set for one project profile.
|
||||
* @param profile - project context used to validate conditional contributions.
|
||||
* @returns ordered builtin feature registry.
|
||||
*/
|
||||
export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry {
|
||||
return new FeatureRegistry(defineFeatures([
|
||||
new ProviderFeature(),
|
||||
new SpineFeature(),
|
||||
{
|
||||
id: 'bash',
|
||||
summary: 'Command execution',
|
||||
mode: 'exclusive',
|
||||
required: true,
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'Local executor',
|
||||
default: true,
|
||||
resources: [{ kind: 'npm-cordis-config-entry', id: 'bash', package: '@deepseek-ai/dsh-bash-local' }],
|
||||
},
|
||||
{
|
||||
id: 'sandbox',
|
||||
label: 'Sandboxed executor',
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'sandbox', package: '@deepseek-ai/dsh-sandbox-local' },
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'bash',
|
||||
package: '@deepseek-ai/dsh-bash-sandbox',
|
||||
commentedExample: `Uncomment to allow writes under the project workspace.
|
||||
config:
|
||||
mode: workspace-write
|
||||
workspaceRoot: !!js process.cwd()`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
new AppFeature(),
|
||||
{
|
||||
id: 'persistence',
|
||||
summary: 'Durable session storage',
|
||||
mode: 'exclusive',
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
id: 'jsonl',
|
||||
label: 'JSONL files',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'session-persistence',
|
||||
package: '@deepseek-ai/dsh-session-persistence-jsonl',
|
||||
config: { root: './.sessions' } satisfies JsonlConfig,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'sqlite',
|
||||
label: 'SQLite database',
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'session-persistence',
|
||||
package: '@deepseek-ai/dsh-session-persistence-sqlite',
|
||||
config: { path: './.sessions/sessions.sqlite' } satisfies SqliteConfig,
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'hmr',
|
||||
summary: 'Hot-module reload',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'Cordis HMR',
|
||||
default: true,
|
||||
resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@cordisjs/plugin-hmr' }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'fs',
|
||||
summary: 'Read, write, and edit local files',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'local',
|
||||
label: 'Local filesystem',
|
||||
default: true,
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'fs-local', package: '@deepseek-ai/dsh-fs-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'fs-policy', package: '@deepseek-ai/dsh-fs-policy' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-fs', package: '@deepseek-ai/dsh-tool-fs' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'todo',
|
||||
summary: 'Model-facing task tracking',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'todo_write tool',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-todo',
|
||||
package: '@deepseek-ai/dsh-tool-todo',
|
||||
config: { allowParallelInProgress: true } satisfies ToolTodoConfig,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'skill',
|
||||
summary: 'Local skill discovery',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'Local skills and skill tool',
|
||||
default: true,
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'skill', package: '@deepseek-ai/dsh-skill' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'skill-local', package: '@deepseek-ai/dsh-skill-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-skill', package: '@deepseek-ai/dsh-tool-skill' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'web',
|
||||
summary: 'Web search and fetch tools',
|
||||
mode: 'exclusive',
|
||||
suggests: ['timeout-policy'],
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'web', package: '@deepseek-ai/dsh-web' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'web-fetch-local', package: '@deepseek-ai/dsh-web-fetch-local' },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'deepseek-official',
|
||||
label: 'DeepSeek search',
|
||||
default: true,
|
||||
markers: [{ id: 'web-search-deepseek', name: '@deepseek-ai/dsh-web-search-deepseek' }],
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'web-search-deepseek', package: '@deepseek-ai/dsh-web-search-deepseek' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'exa',
|
||||
label: 'Exa search',
|
||||
secrets: [{ id: 'apiKey', environment: 'EXA_API_KEY', message: 'Exa API key', required: true }],
|
||||
markers: [{ id: 'web-search-exa', name: '@deepseek-ai/dsh-web-search-exa' }],
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'web-search-exa', package: '@deepseek-ai/dsh-web-search-exa' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'perplexity',
|
||||
label: 'Perplexity search',
|
||||
secrets: [{
|
||||
id: 'apiKey',
|
||||
environment: 'PERPLEXITY_API_KEY',
|
||||
message: 'Perplexity API key',
|
||||
required: true,
|
||||
}],
|
||||
markers: [{ id: 'web-search-perplexity', name: '@deepseek-ai/dsh-web-search-perplexity' }],
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'web-search-perplexity',
|
||||
package: '@deepseek-ai/dsh-web-search-perplexity',
|
||||
},
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fetch-only',
|
||||
label: 'Fetch only',
|
||||
markers: [{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { search: false } }],
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-web',
|
||||
package: '@deepseek-ai/dsh-tool-web',
|
||||
config: { search: false } satisfies ToolWebConfig,
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'subagent',
|
||||
summary: 'Delegate work to child agents',
|
||||
mode: 'multiple',
|
||||
// In-process options select continuable background delegation; the
|
||||
// follow-up adapter remains an independently loadable global tool.
|
||||
baseResources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' },
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' },
|
||||
],
|
||||
options: [
|
||||
{
|
||||
id: 'spawn',
|
||||
label: 'Fresh child agent',
|
||||
default: true,
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subagent-spawn', package: '@deepseek-ai/dsh-subagent-spawn' },
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-subagent',
|
||||
package: '@deepseek-ai/dsh-tool-subagent',
|
||||
config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fork',
|
||||
label: 'Fork parent history',
|
||||
resources: [
|
||||
{ kind: 'npm-cordis-config-entry', id: 'subagent-fork', package: '@deepseek-ai/dsh-subagent-fork' },
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-subagent-fork',
|
||||
package: '@deepseek-ai/dsh-tool-subagent',
|
||||
config: {
|
||||
provider: 'fork',
|
||||
toolName: 'subagent_fork',
|
||||
backgroundMode: 'continuable',
|
||||
} satisfies ToolSubagentConfig,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
summary: 'Scripted multi-agent workflows',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'workerthread',
|
||||
label: 'Worker thread engine',
|
||||
default: true,
|
||||
requires: [{ id: 'subagent', options: ['spawn'] }],
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'workflow-workerthread',
|
||||
package: '@deepseek-ai/dsh-workflow-workerthread',
|
||||
},
|
||||
{ kind: 'npm-cordis-config-entry', id: 'tool-workflow', package: '@deepseek-ai/dsh-tool-workflow' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'compact',
|
||||
summary: 'Automatic context compaction',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'basic',
|
||||
label: 'Basic compaction',
|
||||
default: true,
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'token-meter',
|
||||
package: '@deepseek-ai/dsh-token-meter',
|
||||
},
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'compact-basic',
|
||||
package: '@deepseek-ai/dsh-compact-basic',
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'hooks',
|
||||
summary: 'Run Claude Code or Codex hooks',
|
||||
mode: 'multiple',
|
||||
requires: [{ id: 'bash' }],
|
||||
options: [
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude Code hooks',
|
||||
default: true,
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'hooks-claude',
|
||||
package: '@deepseek-ai/dsh-hooks-claude',
|
||||
config: { configPath: './hooks.json' } satisfies ClaudeHooksConfig,
|
||||
},
|
||||
{ kind: 'owned-file', path: 'hooks.json', text: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex hooks',
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'hooks-codex',
|
||||
package: '@deepseek-ai/dsh-hooks-codex',
|
||||
config: { configPath: './codex-hooks.json' } satisfies CodexHooksConfig,
|
||||
},
|
||||
{ kind: 'owned-file', path: 'codex-hooks.json', text: '{}' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'guard',
|
||||
summary: 'Loop-hygiene reminders',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'repeat-tool',
|
||||
label: 'Repeat-tool reminders',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'repeat-tool-guard',
|
||||
package: '@deepseek-ai/dsh-repeat-tool-guard',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'timeout-policy',
|
||||
summary: 'Tool timeout policy',
|
||||
mode: 'single',
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'Timeout policy',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'timeout-policy',
|
||||
package: '@deepseek-ai/dsh-timeout-policy',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]), profile)
|
||||
}
|
||||
108
packages/scaffold/helper/src/features/builtin/provider.ts
Normal file
108
packages/scaffold/helper/src/features/builtin/provider.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Required direct-fetch DeepSeek and custom pi-ai provider behavior.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/provider
|
||||
*/
|
||||
|
||||
import { featureId } from '../../ids.ts'
|
||||
import type { FeatureSelection, ProjectProfile } from '../../project/types.ts'
|
||||
import {
|
||||
FeatureOption,
|
||||
ExclusiveOptionFeature,
|
||||
type FeatureProjectView,
|
||||
} from '../feature.ts'
|
||||
import { ProjectContribution } from '../resources.ts'
|
||||
import { npmCordisConfigEntry, environment } from './helpers.ts'
|
||||
|
||||
const ID = featureId('provider')
|
||||
const DEFAULT_MODEL = 'deepseek-v4-flash'
|
||||
const API_KEY_COMMENT = 'Required before the first model request.'
|
||||
|
||||
class DeepSeekOption extends FeatureOption {
|
||||
override readonly id = 'deepseek-official'
|
||||
override readonly label = 'DeepSeek'
|
||||
override readonly secrets = [{
|
||||
id: 'apiKey',
|
||||
environment: 'DEEPSEEK_API_KEY',
|
||||
message: 'DeepSeek API key',
|
||||
required: true,
|
||||
}]
|
||||
|
||||
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
|
||||
return new ProjectContribution([
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'llm-deepseek',
|
||||
name: '@deepseek-ai/dsh-llm-deepseek',
|
||||
}, ['baseURL', 'models']),
|
||||
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
class CustomOption extends FeatureOption {
|
||||
override readonly id = 'custom'
|
||||
override readonly label = 'Custom endpoint (pi-ai)'
|
||||
override readonly secrets = [{
|
||||
id: 'apiKey',
|
||||
environment: 'DEEPSEEK_API_KEY',
|
||||
message: 'Custom provider API key',
|
||||
required: true,
|
||||
}]
|
||||
override readonly inputs = [{
|
||||
id: 'baseURL',
|
||||
message: 'Custom provider base URL',
|
||||
}]
|
||||
|
||||
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
|
||||
return new ProjectContribution([
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'llm-pi-ai',
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
}, ['baseURL', 'models']),
|
||||
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Required provider feature with DeepSeek and custom pi-ai options. */
|
||||
export class ProviderFeature extends ExclusiveOptionFeature {
|
||||
override readonly id = ID
|
||||
override readonly summary = 'Model provider'
|
||||
override readonly required = true
|
||||
override readonly options = [new DeepSeekOption(), new CustomOption()]
|
||||
|
||||
/** Prefer the direct-fetch adapter and its public endpoint defaults. */
|
||||
override defaultOptions(): readonly string[] {
|
||||
return ['deepseek-official']
|
||||
}
|
||||
|
||||
/** Recover literal endpoint overrides from either provider entry. */
|
||||
override readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection {
|
||||
const base = super.readSelection(project, selection)
|
||||
const entry = project.cordisConfigEntries().find(item => item.id === 'llm-deepseek' || item.id === 'llm-pi-ai')
|
||||
const baseURL = entry?.config?.baseURL
|
||||
return typeof baseURL === 'string' ? { ...base, values: { baseURL } } : base
|
||||
}
|
||||
|
||||
/** Apply explicit endpoint/model overrides while omitting provider defaults. */
|
||||
override contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution {
|
||||
const contribution = super.contribution(selection, profile)
|
||||
const baseURL = selection.values?.baseURL
|
||||
if (baseURL !== undefined && typeof baseURL !== 'string') throw new Error('provider baseURL must be a string')
|
||||
return new ProjectContribution(contribution.resources.map((resource) => {
|
||||
if (resource.kind !== 'cordis-config-entry' || (resource.entry.id !== 'llm-deepseek'
|
||||
&& resource.entry.id !== 'llm-pi-ai')) return resource
|
||||
return {
|
||||
...resource,
|
||||
entry: {
|
||||
...resource.entry,
|
||||
config: {
|
||||
...resource.entry.config,
|
||||
...baseURL ? { baseURL } : {},
|
||||
...profile.runtime.model === DEFAULT_MODEL ? {} : { models: [profile.runtime.model] },
|
||||
},
|
||||
},
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
59
packages/scaffold/helper/src/features/builtin/spine.ts
Normal file
59
packages/scaffold/helper/src/features/builtin/spine.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Required agent-spine feature expressed as top-level Cordis config entries.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/spine
|
||||
*/
|
||||
|
||||
import { featureId } from '../../ids.ts'
|
||||
import type { ProjectProfile } from '../../project/types.ts'
|
||||
import { loadHelperTemplate } from '../../templates/template-assets.ts'
|
||||
import { FeatureOption, FixedFeature } from '../feature.ts'
|
||||
import { ProjectContribution } from '../resources.ts'
|
||||
import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts'
|
||||
|
||||
const ID = featureId('spine')
|
||||
const PERSONA = loadHelperTemplate<Record<string, never>>('persona.txt.tpl').render({}).trimEnd()
|
||||
|
||||
function emptyAgentsDiagnostics(config: Readonly<Record<string, unknown>>): string[] {
|
||||
const agents = config.agents
|
||||
if (!Array.isArray(agents)) return ['agents must be an array']
|
||||
return agents.length === 0 ? [] : ['agents must be empty']
|
||||
}
|
||||
|
||||
class SpineOption extends FeatureOption {
|
||||
override readonly id = 'default'
|
||||
override readonly label = 'Default agent spine'
|
||||
|
||||
override contribution(_profile: ProjectProfile): ProjectContribution {
|
||||
return new ProjectContribution([
|
||||
...npmCordisConfigEntry(ID, { id: 'timer', name: '@cordisjs/plugin-timer' }),
|
||||
...npmCordisConfigEntry(ID, { id: 'llm', name: '@deepseek-ai/dsh-llm' }),
|
||||
...npmCordisConfigEntry(ID, { id: 'session', name: '@deepseek-ai/dsh-session' }),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'system-prompt',
|
||||
name: '@deepseek-ai/dsh-system-prompt',
|
||||
config: { persona: PERSONA },
|
||||
}, ['persona'], config => requiredString(config, 'persona')),
|
||||
...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []),
|
||||
...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }),
|
||||
...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }),
|
||||
cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }),
|
||||
cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }),
|
||||
...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }),
|
||||
cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'agent-loop',
|
||||
name: '@deepseek-ai/dsh-agent-loop',
|
||||
config: { agents: [] },
|
||||
}, ['agents'], emptyAgentsDiagnostics),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Required providerless agent spine without a composition bundle entry. */
|
||||
export class SpineFeature extends FixedFeature {
|
||||
override readonly id = ID
|
||||
override readonly summary = 'Agent runtime spine'
|
||||
override readonly required = true
|
||||
override readonly options = [new SpineOption()]
|
||||
}
|
||||
286
packages/scaffold/helper/src/features/define-feature.ts
Normal file
286
packages/scaffold/helper/src/features/define-feature.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Typed declarative definitions for features whose behavior is entirely
|
||||
* the shared resource lifecycle.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/define-feature
|
||||
*/
|
||||
|
||||
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import { TextProjectFile } from '../documents/project-file.ts'
|
||||
import { featureId, resourceKey, type FeatureId } from '../ids.ts'
|
||||
import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts'
|
||||
import {
|
||||
Feature,
|
||||
FeatureOption,
|
||||
type FeatureRequirement,
|
||||
type FeatureSecret,
|
||||
} from './feature.ts'
|
||||
import { ProjectContribution, type ProjectResource } from './resources.ts'
|
||||
|
||||
/** Static NPM dependency in a declarative feature. */
|
||||
interface NpmDependencySpec {
|
||||
kind: 'npm-dependency'
|
||||
name: string
|
||||
section?: 'dependencies' | 'devDependencies'
|
||||
}
|
||||
|
||||
/** Bare-package Cordis config entry that also contributes its NPM dependency. */
|
||||
interface NpmCordisConfigEntrySpec {
|
||||
kind: 'npm-cordis-config-entry'
|
||||
id: string
|
||||
package: string
|
||||
config?: Readonly<Record<string, unknown>>
|
||||
ownedConfigKeys?: readonly string[]
|
||||
commentedExample?: string
|
||||
}
|
||||
|
||||
/** Relative or absolute file Cordis config entry with no NPM dependency. */
|
||||
interface FileCordisConfigEntrySpec {
|
||||
kind: 'file-cordis-config-entry'
|
||||
id: string
|
||||
path: string
|
||||
config?: Readonly<Record<string, unknown>>
|
||||
ownedConfigKeys?: readonly string[]
|
||||
commentedExample?: string
|
||||
}
|
||||
|
||||
/** Static complete file owned by one feature option. */
|
||||
interface OwnedFileSpec {
|
||||
kind: 'owned-file'
|
||||
path: string
|
||||
text: string
|
||||
removeOnlyWhenUnchanged?: boolean
|
||||
}
|
||||
|
||||
/** Resource forms that require no feature-specific imperative code. */
|
||||
type FeatureResourceSpec =
|
||||
| NpmDependencySpec
|
||||
| NpmCordisConfigEntrySpec
|
||||
| FileCordisConfigEntrySpec
|
||||
| OwnedFileSpec
|
||||
|
||||
/** Cordis config entry identity and optional plugin-config subset that identifies an option. */
|
||||
interface FeatureOptionMarkerSpec {
|
||||
id: string
|
||||
name: string
|
||||
config?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Declarative requirement converted to branded domain identity at the boundary. */
|
||||
interface FeatureRequirementSpec {
|
||||
id: string
|
||||
options?: readonly string[]
|
||||
}
|
||||
|
||||
/** One static option inside a typed feature definition. */
|
||||
interface FeatureOptionSpec {
|
||||
id: string
|
||||
label: string
|
||||
default?: boolean
|
||||
resources: readonly FeatureResourceSpec[]
|
||||
secrets?: readonly FeatureSecret[]
|
||||
markers?: readonly FeatureOptionMarkerSpec[]
|
||||
requires?: readonly FeatureRequirementSpec[]
|
||||
}
|
||||
|
||||
/** Complete declarative feature definition. */
|
||||
export interface FeatureSpec {
|
||||
id: string
|
||||
summary: string
|
||||
mode: 'single' | 'exclusive' | 'multiple'
|
||||
options: readonly FeatureOptionSpec[]
|
||||
baseResources?: readonly FeatureResourceSpec[]
|
||||
required?: boolean
|
||||
requires?: readonly FeatureRequirementSpec[]
|
||||
suggests?: readonly string[]
|
||||
supportedInterfaces?: readonly RunInterface[]
|
||||
}
|
||||
|
||||
function sameShape(expected: unknown, actual: unknown): boolean {
|
||||
if (expected === null || actual === null) return expected === actual
|
||||
if (Array.isArray(expected)) {
|
||||
return Array.isArray(actual) && (expected.length === 0 || actual.every(item => sameShape(expected[0], item)))
|
||||
}
|
||||
if (typeof expected !== 'object') return typeof expected === typeof actual
|
||||
if (typeof actual !== 'object' || Array.isArray(actual)) return false
|
||||
return Object.entries(expected as Record<string, unknown>).every(
|
||||
([key, value]) => sameShape(value, (actual as Record<string, unknown>)[key]),
|
||||
)
|
||||
}
|
||||
|
||||
function configDiagnostics(
|
||||
expected: Readonly<Record<string, unknown>> | undefined,
|
||||
): ((config: Readonly<Record<string, unknown>>) => readonly string[]) | undefined {
|
||||
if (!expected || Object.keys(expected).length === 0) return undefined
|
||||
return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key])
|
||||
? []
|
||||
: [`${key} has an incompatible value shape`])
|
||||
}
|
||||
|
||||
function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] {
|
||||
switch (spec.kind) {
|
||||
case 'npm-dependency':
|
||||
return [{
|
||||
kind: 'npm-dependency',
|
||||
key: resourceKey(`npm-dependency:${spec.name}`),
|
||||
name: spec.name,
|
||||
section: spec.section ?? 'dependencies',
|
||||
}]
|
||||
case 'npm-cordis-config-entry':
|
||||
case 'file-cordis-config-entry': {
|
||||
const config = spec.config ? { ...spec.config } : undefined
|
||||
const validateConfig = configDiagnostics(config)
|
||||
const name = spec.kind === 'npm-cordis-config-entry' ? spec.package : spec.path
|
||||
return [
|
||||
...spec.kind === 'npm-cordis-config-entry'
|
||||
? [{
|
||||
kind: 'npm-dependency' as const,
|
||||
key: resourceKey(`npm-dependency:${spec.package}`),
|
||||
name: spec.package,
|
||||
section: 'dependencies' as const,
|
||||
}]
|
||||
: [],
|
||||
{
|
||||
kind: 'cordis-config-entry',
|
||||
key: resourceKey(`cordis-config-entry:${spec.id}`),
|
||||
entry: {
|
||||
id: spec.id,
|
||||
name,
|
||||
...config ? { config } : {},
|
||||
},
|
||||
ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}),
|
||||
...spec.commentedExample ? { commentedExample: spec.commentedExample } : {},
|
||||
...validateConfig ? { validateConfig } : {},
|
||||
},
|
||||
]
|
||||
}
|
||||
case 'owned-file':
|
||||
return [{
|
||||
kind: 'owned-file',
|
||||
key: resourceKey(`file:${spec.path}`),
|
||||
document: new TextProjectFile(spec.path, spec.text),
|
||||
removeOnlyWhenUnchanged: spec.removeOnlyWhenUnchanged ?? true,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
function isSubset(expected: Readonly<Record<string, unknown>>, actual: Readonly<Record<string, unknown>>): boolean {
|
||||
return Object.entries(expected).every(([key, value]) => Object.is(actual[key], value))
|
||||
}
|
||||
|
||||
class DefinedFeatureOption extends FeatureOption {
|
||||
override readonly id: string
|
||||
override readonly label: string
|
||||
override readonly secrets: readonly FeatureSecret[]
|
||||
private readonly spec: FeatureOptionSpec
|
||||
|
||||
constructor(spec: FeatureOptionSpec) {
|
||||
super()
|
||||
this.spec = spec
|
||||
this.id = spec.id
|
||||
this.label = spec.label
|
||||
this.secrets = spec.secrets ?? []
|
||||
}
|
||||
|
||||
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
|
||||
return new ProjectContribution([
|
||||
...this.spec.resources.flatMap(resourcesFromSpec),
|
||||
...this.secrets.map(secret => ({
|
||||
kind: 'environment' as const,
|
||||
key: resourceKey(`environment:${secret.environment}`),
|
||||
name: secret.environment,
|
||||
...secrets[secret.id] === undefined ? {} : { value: secrets[secret.id] },
|
||||
exampleValue: '',
|
||||
})),
|
||||
])
|
||||
}
|
||||
|
||||
override markerConfigEntries(): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
|
||||
const markers = this.spec.markers ?? this.spec.resources.flatMap((resource) => {
|
||||
switch (resource.kind) {
|
||||
case 'npm-cordis-config-entry': return [{ id: resource.id, name: resource.package }]
|
||||
case 'file-cordis-config-entry': return [{ id: resource.id, name: resource.path }]
|
||||
default: return []
|
||||
}
|
||||
})
|
||||
return markers.map(marker => ({ id: marker.id, name: marker.name }))
|
||||
}
|
||||
|
||||
override matchesConfigEntries(entries: readonly CordisConfigEntry[]): boolean {
|
||||
const markers = this.spec.markers
|
||||
if (!markers) return this.markerConfigEntries().some(marker => entries.some(
|
||||
entry => entry.id === marker.id && entry.name === marker.name,
|
||||
))
|
||||
return markers.some(marker => entries.some(entry => entry.id === marker.id
|
||||
&& entry.name === marker.name
|
||||
&& (!marker.config || isSubset(marker.config, entry.config ?? {}))))
|
||||
}
|
||||
}
|
||||
|
||||
/** Feature entity backed by a typed static definition. */
|
||||
class DefinedFeature extends Feature {
|
||||
override readonly id: FeatureId
|
||||
override readonly summary: string
|
||||
override readonly mode: FeatureSpec['mode']
|
||||
override readonly options: readonly FeatureOption[]
|
||||
override readonly required: boolean
|
||||
override readonly requires: readonly FeatureId[]
|
||||
override readonly suggests: readonly FeatureId[]
|
||||
override readonly supportedInterfaces: readonly RunInterface[]
|
||||
private readonly spec: FeatureSpec
|
||||
|
||||
/** Validate and materialize one declarative definition. */
|
||||
constructor(spec: FeatureSpec) {
|
||||
super()
|
||||
this.spec = spec
|
||||
this.id = featureId(spec.id)
|
||||
this.summary = spec.summary
|
||||
this.mode = spec.mode
|
||||
this.options = spec.options.map(option => new DefinedFeatureOption(option))
|
||||
const defaultCount = spec.options.filter(option => option.default).length
|
||||
if (spec.mode === 'single' && (spec.options.length !== 1 || defaultCount !== 1)) {
|
||||
throw new Error(`single feature ${spec.id} requires one default option`)
|
||||
}
|
||||
if (spec.mode === 'exclusive' && defaultCount !== 1) {
|
||||
throw new Error(`exclusive feature ${spec.id} requires exactly one default option`)
|
||||
}
|
||||
if (spec.mode === 'multiple' && defaultCount === 0) {
|
||||
throw new Error(`multiple feature ${spec.id} requires at least one default option`)
|
||||
}
|
||||
this.required = spec.required ?? false
|
||||
this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id))
|
||||
this.suggests = (spec.suggests ?? []).map(featureId)
|
||||
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'embed']
|
||||
}
|
||||
|
||||
override defaultOptions(): readonly string[] {
|
||||
return this.spec.options.filter(option => option.default).map(option => option.id)
|
||||
}
|
||||
|
||||
override baseContribution(): ProjectContribution {
|
||||
return new ProjectContribution((this.spec.baseResources ?? []).flatMap(resourcesFromSpec))
|
||||
}
|
||||
|
||||
override requirements(selection: FeatureSelection): readonly FeatureRequirement[] {
|
||||
const selected = new Set(selection.options)
|
||||
return [
|
||||
...(this.spec.requires ?? []),
|
||||
...this.spec.options.filter(option => selected.has(option.id)).flatMap(option => option.requires ?? []),
|
||||
].map(requirement => ({
|
||||
id: featureId(requirement.id),
|
||||
...requirement.options ? { options: requirement.options } : {},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct the shared lifecycle entity from a typed declarative definition. */
|
||||
export function defineFeature(spec: FeatureSpec): Feature {
|
||||
return new DefinedFeature(spec)
|
||||
}
|
||||
|
||||
/** Materialize one ordered catalog containing static specs and behavior entities. */
|
||||
export function defineFeatures(definitions: readonly (Feature | FeatureSpec)[]): Feature[] {
|
||||
return definitions.map(definition => definition instanceof Feature
|
||||
? definition
|
||||
: defineFeature(definition))
|
||||
}
|
||||
111
packages/scaffold/helper/src/features/feature-configurator.ts
Normal file
111
packages/scaffold/helper/src/features/feature-configurator.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Shared option and secret question flow for create and config.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/feature-configurator
|
||||
*/
|
||||
|
||||
import type { Feature } from './feature.ts'
|
||||
import type { FeatureSelection, ProjectProfile } from '../project/types.ts'
|
||||
import type { PromptPort } from '../questions/prompt-port.ts'
|
||||
import { requireAnswer } from '../questions/prompt-port.ts'
|
||||
import { MultiSelectQuestion, SecretQuestion, SelectQuestion, TextQuestion } from '../questions/question.ts'
|
||||
|
||||
/** Resolve one feature selection without knowing which workflow requested it. */
|
||||
export class FeatureConfigurator {
|
||||
private readonly port: PromptPort
|
||||
|
||||
/** Bind the configurator to the shared prompt boundary. */
|
||||
constructor(port: PromptPort) {
|
||||
this.port = port
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask option and input questions, preserving current secrets on empty input.
|
||||
* @param feature - feature whose options and inputs are collected.
|
||||
* @param profile - target project context.
|
||||
* @param current - currently installed selection, when configuring.
|
||||
* @param prefilledOptions - options already chosen by a tree picker.
|
||||
* @param prefilledSecrets - non-interactive secret values supplied by creation.
|
||||
* @param prefilledValues - non-interactive value inputs supplied by a headless spec.
|
||||
* @returns normalized selection with captured values and secrets.
|
||||
*/
|
||||
async configure(
|
||||
feature: Feature,
|
||||
profile: ProjectProfile,
|
||||
current?: FeatureSelection,
|
||||
prefilledOptions?: readonly string[],
|
||||
prefilledSecrets: Readonly<Record<string, string>> = {},
|
||||
prefilledValues: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<FeatureSelection> {
|
||||
let options: readonly string[]
|
||||
switch (feature.mode) {
|
||||
case 'single':
|
||||
options = feature.defaultOptions(profile)
|
||||
break
|
||||
case 'exclusive': {
|
||||
const initialValue = current?.options[0] ?? feature.defaultOptions(profile)[0]
|
||||
if (initialValue === undefined) throw new Error(`feature ${feature.id} has no default option`)
|
||||
const question = new SelectQuestion({
|
||||
id: `${feature.id}.option`,
|
||||
message: `Choose ${feature.summary.toLowerCase()}`,
|
||||
options: feature.options.map(option => ({ value: option.id, label: option.label })),
|
||||
initialValue,
|
||||
})
|
||||
const prefilled = prefilledOptions?.[0]
|
||||
options = [requireAnswer(await question.resolve(this.port, prefilled))]
|
||||
break
|
||||
}
|
||||
case 'multiple': {
|
||||
const question = new MultiSelectQuestion({
|
||||
id: `${feature.id}.options`,
|
||||
message: `Choose ${feature.summary.toLowerCase()}`,
|
||||
options: feature.options.map(option => ({ value: option.id, label: option.label })),
|
||||
initialValues: current?.options ?? feature.defaultOptions(profile),
|
||||
required: true,
|
||||
})
|
||||
options = requireAnswer(await question.resolve(this.port, prefilledOptions))
|
||||
break
|
||||
}
|
||||
}
|
||||
const selected: FeatureSelection = {
|
||||
id: feature.id,
|
||||
options,
|
||||
}
|
||||
const coercedPrefilled: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(prefilledValues)) {
|
||||
if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
|
||||
coercedPrefilled[key] = value
|
||||
}
|
||||
const values: Record<string, string> = {}
|
||||
for (const input of feature.valueInputs(selected, profile)) {
|
||||
const existing = current?.values?.[input.id]
|
||||
if (existing !== undefined && typeof existing !== 'string') {
|
||||
throw new Error(`${feature.id}.${input.id} current value must be a string`)
|
||||
}
|
||||
const question = new TextQuestion({
|
||||
id: `${feature.id}.${input.id}`,
|
||||
message: input.message,
|
||||
...existing === undefined ? {} : { initialValue: existing },
|
||||
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
|
||||
})
|
||||
values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
|
||||
}
|
||||
const base: FeatureSelection = Object.keys(values).length === 0
|
||||
? selected
|
||||
: { ...selected, values }
|
||||
const secrets = { ...current?.secrets }
|
||||
for (const secret of feature.secrets(base, profile)) {
|
||||
const existing = secrets[secret.id]
|
||||
const question = new SecretQuestion({
|
||||
id: `${feature.id}.${secret.id}`,
|
||||
message: existing === undefined ? secret.message : `${secret.message} (leave empty to keep current)`,
|
||||
validate: value => secret.required && existing === undefined && value.length === 0
|
||||
? 'A value is required'
|
||||
: undefined,
|
||||
})
|
||||
const answer = requireAnswer(await question.resolve(this.port, prefilledSecrets[secret.id]))
|
||||
if (answer.length > 0) secrets[secret.id] = answer
|
||||
}
|
||||
return Object.keys(secrets).length === 0 ? base : { ...base, secrets }
|
||||
}
|
||||
}
|
||||
345
packages/scaffold/helper/src/features/feature.ts
Normal file
345
packages/scaffold/helper/src/features/feature.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Stateful builtin feature and option domain objects.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/feature
|
||||
*/
|
||||
|
||||
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import type { PackageManifest } from '../documents/package-json-file.ts'
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts'
|
||||
import { ProjectContribution, type CordisConfigEntryResource, type ProjectResource } from './resources.ts'
|
||||
|
||||
/** Read-only project surface used by feature inspection. */
|
||||
export interface FeatureProjectView {
|
||||
readonly profile: ProjectProfile
|
||||
cordisConfigEntries(): readonly CordisConfigEntry[]
|
||||
packageManifest(): Readonly<PackageManifest>
|
||||
hasDocument(path: string): boolean
|
||||
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined
|
||||
}
|
||||
|
||||
/** Installation state visible to create/config workflows. */
|
||||
type FeatureInstallationState = 'absent' | 'enabled' | 'disabled' | 'inconsistent'
|
||||
|
||||
/** Result of round-tripping one feature from a project snapshot. */
|
||||
export interface FeatureInstallation {
|
||||
id: FeatureId
|
||||
state: FeatureInstallationState
|
||||
options: readonly string[]
|
||||
selection?: FeatureSelection
|
||||
diagnostics: readonly string[]
|
||||
}
|
||||
|
||||
/** One final-state requirement on another builtin feature. */
|
||||
export interface FeatureRequirement {
|
||||
id: FeatureId
|
||||
options?: readonly string[]
|
||||
}
|
||||
|
||||
/** One secret captured into an environment binding rather than Cordis plugin config. */
|
||||
export interface FeatureSecret {
|
||||
id: string
|
||||
environment: string
|
||||
message: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/** One visible string value requested only by options that own it. */
|
||||
export interface FeatureValueInput {
|
||||
id: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** One selectable behavior option owned by a feature. */
|
||||
export abstract class FeatureOption {
|
||||
abstract readonly id: string
|
||||
abstract readonly label: string
|
||||
readonly secrets: readonly FeatureSecret[] = []
|
||||
readonly inputs: readonly FeatureValueInput[] = []
|
||||
|
||||
/** Contribute this option's project resources. */
|
||||
abstract contribution(profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution
|
||||
|
||||
/** Every Cordis config entry package owned by this option during inspection. */
|
||||
ownedConfigEntries(profile: ProjectProfile): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
|
||||
return this.contribution(profile, {}).resources
|
||||
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
|
||||
.map(resource => ({ id: resource.entry.id, name: resource.entry.name }))
|
||||
}
|
||||
|
||||
/** Cordis config entry identities that distinguish this option during inspection. */
|
||||
markerConfigEntries(profile: ProjectProfile): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
|
||||
return this.ownedConfigEntries(profile)
|
||||
}
|
||||
|
||||
/** Whether current owned Cordis config entries identify this option. */
|
||||
matchesConfigEntries(entries: readonly CordisConfigEntry[], profile: ProjectProfile): boolean {
|
||||
return this.markerConfigEntries(profile).some(marker => entries.some(
|
||||
entry => entry.id === marker.id && entry.name === marker.name,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** How a feature's options compose. */
|
||||
export type FeatureOptionMode = 'single' | 'exclusive' | 'multiple'
|
||||
|
||||
function packageNames(resources: readonly ProjectResource[]): Set<string> {
|
||||
return new Set(resources
|
||||
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
|
||||
.map(resource => resource.entry.name))
|
||||
}
|
||||
|
||||
function configDiagnostics(resource: CordisConfigEntryResource, entry: CordisConfigEntry): string[] {
|
||||
/* v8 ignore next -- entries without validators have no diagnostics to compute */
|
||||
if (!resource.validateConfig) return []
|
||||
return [...resource.validateConfig(entry.config ?? {})].map(message => `${entry.id}: ${message}`)
|
||||
}
|
||||
|
||||
/** A behavior-owning builtin feature with shallow option composition. */
|
||||
export abstract class Feature {
|
||||
/** Stable registry identity. */
|
||||
abstract readonly id: FeatureId
|
||||
/** User-facing feature summary. */
|
||||
abstract readonly summary: string
|
||||
/** Option-selection rule. */
|
||||
abstract readonly mode: FeatureOptionMode
|
||||
/** Available behavior options. */
|
||||
abstract readonly options: readonly FeatureOption[]
|
||||
/** Whether every valid project must enable this feature. */
|
||||
readonly required: boolean = false
|
||||
/** Unconditional feature requirements. */
|
||||
readonly requires: readonly FeatureId[] = []
|
||||
/** Features recommended during creation. */
|
||||
readonly suggests: readonly FeatureId[] = []
|
||||
/** Front doors under which this feature is meaningful. */
|
||||
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed']
|
||||
|
||||
/**
|
||||
* Options selected when installation has no override.
|
||||
* @param profile - project context controlling applicable defaults.
|
||||
* @returns selected option ids.
|
||||
*/
|
||||
abstract defaultOptions(profile: ProjectProfile): readonly string[]
|
||||
|
||||
/**
|
||||
* Shared resources present for every installed option set.
|
||||
* @param _profile - project context available to behavior features.
|
||||
* @returns shared project contribution.
|
||||
*/
|
||||
baseContribution(_profile: ProjectProfile): ProjectContribution {
|
||||
return new ProjectContribution([])
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional final-state requirements depending on selected options.
|
||||
* @param _selection - normalized feature selection.
|
||||
* @returns required features and option constraints.
|
||||
*/
|
||||
requirements(_selection: FeatureSelection): readonly FeatureRequirement[] {
|
||||
return this.requires.map(id => ({ id }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the feature may be selected for this project front door.
|
||||
* @param profile - project context to check.
|
||||
* @returns whether the feature applies.
|
||||
*/
|
||||
isApplicable(profile: ProjectProfile): boolean {
|
||||
return this.supportedInterfaces.includes(profile.runInterface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize one requested option set.
|
||||
* @param selection - requested feature and options.
|
||||
* @param profile - project context for applicability and defaults.
|
||||
* @returns deduplicated, sorted selection.
|
||||
*/
|
||||
normalizeSelection(selection: FeatureSelection, profile: ProjectProfile): FeatureSelection {
|
||||
if (selection.id !== this.id) throw new Error(`selection ${selection.id} does not belong to feature ${this.id}`)
|
||||
if (!this.isApplicable(profile)) {
|
||||
throw new Error(`feature ${this.id} is not available for ${profile.runInterface}`)
|
||||
}
|
||||
const available = new Set(this.options.map(option => option.id))
|
||||
const options = [...new Set(selection.options.length > 0 ? selection.options : this.defaultOptions(profile))]
|
||||
for (const option of options) {
|
||||
if (!available.has(option)) throw new Error(`unknown ${this.id} option: ${option}`)
|
||||
}
|
||||
if (this.mode === 'single' && (options.length !== 1 || this.options.length !== 1)) {
|
||||
throw new Error(`feature ${this.id} has one fixed option`)
|
||||
}
|
||||
if (this.mode === 'exclusive' && options.length !== 1) {
|
||||
throw new Error(`feature ${this.id} requires exactly one option`)
|
||||
}
|
||||
if (this.mode === 'multiple' && options.length === 0) {
|
||||
throw new Error(`feature ${this.id} requires at least one option`)
|
||||
}
|
||||
return { ...selection, options: options.sort() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete selected resource contribution.
|
||||
* @param selection - selected options and captured inputs.
|
||||
* @param profile - target project context.
|
||||
* @returns merged base and option resources.
|
||||
*/
|
||||
contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution {
|
||||
const normalized = this.normalizeSelection(selection, profile)
|
||||
const selected = this.selectedOptions(normalized)
|
||||
.map(option => option.contribution(profile, normalized.secrets ?? {}))
|
||||
return ProjectContribution.merge(this.baseContribution(profile), ...selected)
|
||||
}
|
||||
|
||||
/**
|
||||
* All secret definitions required by one selected option set.
|
||||
* @param selection - selected options.
|
||||
* @param profile - target project context.
|
||||
* @returns selected secret definitions.
|
||||
*/
|
||||
secrets(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureSecret[] {
|
||||
const normalized = this.normalizeSelection(selection, profile)
|
||||
return this.selectedOptions(normalized).flatMap(option => option.secrets)
|
||||
}
|
||||
|
||||
/**
|
||||
* All visible value definitions required by one selected option set.
|
||||
* @param selection - selected options.
|
||||
* @param profile - target project context.
|
||||
* @returns selected visible-input definitions.
|
||||
*/
|
||||
valueInputs(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureValueInput[] {
|
||||
const normalized = this.normalizeSelection(selection, profile)
|
||||
return this.selectedOptions(normalized).flatMap(option => option.inputs)
|
||||
}
|
||||
|
||||
private selectedOptions(selection: FeatureSelection): readonly FeatureOption[] {
|
||||
return selection.options.map((id) => {
|
||||
const option = this.options.find(candidate => candidate.id === id)
|
||||
/* v8 ignore next -- normalizeSelection already membership-checks every selected id */
|
||||
if (!option) throw new Error(`unknown ${this.id} option: ${id}`)
|
||||
return option
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover input and secret values after structural inspection.
|
||||
* @param project - project snapshot being inspected.
|
||||
* @param selection - structurally detected selection.
|
||||
* @returns selection enriched with readable values.
|
||||
*/
|
||||
readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection {
|
||||
const secrets = Object.fromEntries(this.secrets(selection, project.profile).flatMap((secret) => {
|
||||
const value = project.readEnvironment('.env', secret.environment)
|
||||
return value === undefined ? [] : [[secret.id, value]]
|
||||
}))
|
||||
return Object.keys(secrets).length === 0 ? selection : { ...selection, secrets }
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect current files and reject any partial or ambiguous owned shape.
|
||||
* @param project - project snapshot to inspect.
|
||||
* @returns installation state, selection, and diagnostics.
|
||||
*/
|
||||
inspect(project: FeatureProjectView): FeatureInstallation {
|
||||
const profile = project.profile
|
||||
const allPackages = new Set<string>()
|
||||
for (const option of this.options) {
|
||||
for (const entry of option.ownedConfigEntries(profile)) allPackages.add(entry.name)
|
||||
}
|
||||
for (const name of packageNames(this.baseContribution(profile).resources)) allPackages.add(name)
|
||||
const configEntries = project.cordisConfigEntries()
|
||||
const ownedConfigEntries = configEntries.filter(entry => allPackages.has(entry.name))
|
||||
|
||||
const options = this.options
|
||||
.filter(option => option.matchesConfigEntries(configEntries, profile))
|
||||
.map(option => option.id)
|
||||
if (ownedConfigEntries.length === 0 && options.length === 0) {
|
||||
return { id: this.id, state: 'absent', options: [], diagnostics: [] }
|
||||
}
|
||||
let selection: FeatureSelection
|
||||
try {
|
||||
selection = this.normalizeSelection({ id: this.id, options }, profile)
|
||||
} catch (error) {
|
||||
return { id: this.id, state: 'inconsistent', options, diagnostics: [String(error)] }
|
||||
}
|
||||
selection = this.readSelection(project, selection)
|
||||
const expected = this.contribution(selection, profile)
|
||||
const expectedEntries = expected.resources
|
||||
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
|
||||
const diagnostics: string[] = []
|
||||
for (const resource of expectedEntries) {
|
||||
const actual = ownedConfigEntries.find(entry => entry.id === resource.entry.id && entry.name === resource.entry.name)
|
||||
if (!actual) diagnostics.push(`missing Cordis config entry ${resource.entry.id} (${resource.entry.name})`)
|
||||
else diagnostics.push(...configDiagnostics(resource, actual))
|
||||
}
|
||||
for (const actual of ownedConfigEntries) {
|
||||
if (!expectedEntries.some(resource => resource.entry.id === actual.id && resource.entry.name === actual.name)) {
|
||||
diagnostics.push(`unexpected owned Cordis config entry ${actual.id} (${actual.name})`)
|
||||
}
|
||||
}
|
||||
const manifest = project.packageManifest()
|
||||
for (const resource of expected.resources) {
|
||||
switch (resource.kind) {
|
||||
case 'npm-dependency':
|
||||
if (!manifest[resource.section]?.[resource.name]) {
|
||||
diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`)
|
||||
}
|
||||
break
|
||||
case 'package-script':
|
||||
if (!manifest.scripts?.[resource.name]) {
|
||||
diagnostics.push(`missing package.json script ${resource.name}`)
|
||||
}
|
||||
break
|
||||
case 'owned-file':
|
||||
if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`)
|
||||
break
|
||||
case 'environment':
|
||||
try {
|
||||
if (project.readEnvironment('.env.example', resource.name) === undefined) {
|
||||
diagnostics.push(`missing .env.example variable ${resource.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.push(String(error))
|
||||
}
|
||||
break
|
||||
case 'cordis-config-entry': break
|
||||
}
|
||||
}
|
||||
const disabled = ownedConfigEntries.map(entry => entry.disabled === true)
|
||||
if (disabled.some(Boolean) && disabled.some(value => !value)) {
|
||||
diagnostics.push('owned Cordis config entries have mixed enabled states')
|
||||
}
|
||||
if (diagnostics.length > 0) {
|
||||
return { id: this.id, state: 'inconsistent', options, diagnostics }
|
||||
}
|
||||
return {
|
||||
id: this.id,
|
||||
state: ownedConfigEntries.length > 0 && disabled.every(Boolean) ? 'disabled' : 'enabled',
|
||||
options,
|
||||
selection,
|
||||
diagnostics: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fixed one-option feature base. */
|
||||
export abstract class FixedFeature extends Feature {
|
||||
override readonly mode = 'single'
|
||||
|
||||
/** Select the sole option. */
|
||||
override defaultOptions(): readonly string[] {
|
||||
const option = this.options[0]
|
||||
if (!option) throw new Error(`simple feature ${this.id} has no option`)
|
||||
return [option.id]
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutually exclusive option feature base. */
|
||||
export abstract class ExclusiveOptionFeature extends Feature {
|
||||
override readonly mode = 'exclusive'
|
||||
}
|
||||
|
||||
/** Additive multi-option feature base. */
|
||||
export abstract class MultiOptionFeature extends Feature {
|
||||
override readonly mode = 'multiple'
|
||||
}
|
||||
87
packages/scaffold/helper/src/features/registry.ts
Normal file
87
packages/scaffold/helper/src/features/registry.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Builtin feature registry and definition-time conflict checks.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/registry
|
||||
*/
|
||||
|
||||
import type { FeatureId, ResourceKey } from '../ids.ts'
|
||||
import type { ProjectProfile } from '../project/types.ts'
|
||||
import type { Feature, FeatureProjectView } from './feature.ts'
|
||||
import type { CordisConfigEntryResource } from './resources.ts'
|
||||
|
||||
/** Compile-time builtin feature collection. */
|
||||
export class FeatureRegistry {
|
||||
private readonly features = new Map<FeatureId, Feature>()
|
||||
|
||||
/** Register and validate a complete builtin set. */
|
||||
constructor(features: readonly Feature[], validationProfile: ProjectProfile) {
|
||||
const owners = new Map<ResourceKey, FeatureId>()
|
||||
for (const feature of features) {
|
||||
if (this.features.has(feature.id)) throw new Error(`duplicate feature id: ${feature.id}`)
|
||||
this.features.set(feature.id, feature)
|
||||
const validationInterface = feature.supportedInterfaces[0]
|
||||
if (!validationInterface) throw new Error(`feature ${feature.id} supports no run interface`)
|
||||
const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] }))
|
||||
for (const selection of selections) {
|
||||
const contribution = feature.contribution(selection, {
|
||||
...validationProfile,
|
||||
runInterface: validationInterface,
|
||||
})
|
||||
for (const resource of contribution.resources) {
|
||||
const owner = owners.get(resource.key)
|
||||
if (owner && owner !== feature.id) {
|
||||
throw new Error(`resource ${resource.key} is declared by both ${owner} and ${feature.id}`)
|
||||
}
|
||||
owners.set(resource.key, feature.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all builtins in display order.
|
||||
* @returns all registered features.
|
||||
*/
|
||||
all(): readonly Feature[] {
|
||||
return [...this.features.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one builtin or fail loud.
|
||||
* @param id - stable feature identity.
|
||||
* @returns registered feature.
|
||||
*/
|
||||
get(id: FeatureId): Feature {
|
||||
const feature = this.features.get(id)
|
||||
if (!feature) throw new Error(`unknown feature: ${id}`)
|
||||
return feature
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect every applicable builtin in display order.
|
||||
* @param project - project view to inspect.
|
||||
* @returns installation snapshots for applicable features.
|
||||
*/
|
||||
inspect(project: FeatureProjectView): ReturnType<Feature['inspect']>[] {
|
||||
return this.all()
|
||||
.filter(feature => feature.isApplicable(project.profile))
|
||||
.map(feature => feature.inspect(project))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the builtin that owns a Cordis package name for this profile.
|
||||
* @param name - Loader package name.
|
||||
* @param profile - project context controlling applicability.
|
||||
* @returns owning feature, if the package is builtin-owned.
|
||||
*/
|
||||
ownerOfPackage(name: string, profile: ProjectProfile): Feature | undefined {
|
||||
return this.all().find((feature) => {
|
||||
if (!feature.isApplicable(profile)) return false
|
||||
const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] }))
|
||||
return selections.some(selection => feature.contribution(selection, profile).resources.some(
|
||||
(resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry'
|
||||
&& resource.entry.name === name,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
97
packages/scaffold/helper/src/features/resources.ts
Normal file
97
packages/scaffold/helper/src/features/resources.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Resource vocabulary contributed by builtin SDK features.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/resources
|
||||
*/
|
||||
|
||||
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import type { ProjectFile } from '../documents/project-file.ts'
|
||||
import type { ResourceKey } from '../ids.ts'
|
||||
|
||||
/** Runtime or development NPM dependency contribution. */
|
||||
export interface NpmDependencyResource {
|
||||
kind: 'npm-dependency'
|
||||
key: ResourceKey
|
||||
name: string
|
||||
section: 'dependencies' | 'devDependencies'
|
||||
}
|
||||
|
||||
/** Feature-owned package script. */
|
||||
export interface PackageScriptResource {
|
||||
kind: 'package-script'
|
||||
key: ResourceKey
|
||||
name: string
|
||||
command: string
|
||||
removeOnlyWhenUnchanged: boolean
|
||||
}
|
||||
|
||||
/** Owned Cordis config entry plus the config keys safe to update in place. */
|
||||
export interface CordisConfigEntryResource {
|
||||
kind: 'cordis-config-entry'
|
||||
key: ResourceKey
|
||||
entry: CordisConfigEntry
|
||||
ownedConfigKeys: readonly string[]
|
||||
commentedExample?: string
|
||||
validateConfig?: (config: Readonly<Record<string, unknown>>) => readonly string[]
|
||||
}
|
||||
|
||||
/** Environment variable reference and dotenv material. */
|
||||
export interface EnvironmentResource {
|
||||
kind: 'environment'
|
||||
key: ResourceKey
|
||||
name: string
|
||||
value?: string
|
||||
exampleValue: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
/** Feature-exclusive complete file. */
|
||||
export interface OwnedFileResource {
|
||||
kind: 'owned-file'
|
||||
key: ResourceKey
|
||||
document: ProjectFile
|
||||
removeOnlyWhenUnchanged: boolean
|
||||
}
|
||||
|
||||
/** Any resource a feature can add to a project. */
|
||||
export type ProjectResource =
|
||||
| NpmDependencyResource
|
||||
| PackageScriptResource
|
||||
| CordisConfigEntryResource
|
||||
| EnvironmentResource
|
||||
| OwnedFileResource
|
||||
|
||||
/** Complete resource contribution for one selected feature state. */
|
||||
export class ProjectContribution {
|
||||
readonly resources: readonly ProjectResource[]
|
||||
|
||||
/** Validate and retain one feature-owned resource set. */
|
||||
constructor(resources: readonly ProjectResource[]) {
|
||||
const seen = new Set<ResourceKey>()
|
||||
for (const resource of resources) {
|
||||
if (seen.has(resource.key)) throw new Error(`duplicate contribution resource key: ${resource.key}`)
|
||||
seen.add(resource.key)
|
||||
}
|
||||
this.resources = resources
|
||||
}
|
||||
|
||||
/** Merge base and option contributions by stable key. */
|
||||
static merge(...contributions: readonly ProjectContribution[]): ProjectContribution {
|
||||
const resources = new Map<ResourceKey, ProjectResource>()
|
||||
for (const contribution of contributions) {
|
||||
for (const resource of contribution.resources) {
|
||||
const previous = resources.get(resource.key)
|
||||
if (previous && JSON.stringify(previous) !== JSON.stringify(resource)) {
|
||||
throw new Error(`resource ${resource.key} has conflicting definitions inside one feature`)
|
||||
}
|
||||
resources.set(resource.key, resource)
|
||||
}
|
||||
}
|
||||
return new ProjectContribution([...resources.values()])
|
||||
}
|
||||
|
||||
/** Index resources by stable key. */
|
||||
byKey(): ReadonlyMap<ResourceKey, ProjectResource> {
|
||||
return new Map(this.resources.map(resource => [resource.key, resource]))
|
||||
}
|
||||
}
|
||||
31
packages/scaffold/helper/src/ids.ts
Normal file
31
packages/scaffold/helper/src/ids.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Branded identities owned by the SDK project domain.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/ids
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Stable identity of a builtin SDK feature. */
|
||||
export type FeatureId = Branded<'FeatureId'>
|
||||
|
||||
/**
|
||||
* Construct a feature identity from its registry key.
|
||||
* @param value - lowercase kebab-case registry key.
|
||||
* @returns branded feature identity.
|
||||
*/
|
||||
export function featureId(value: string): FeatureId {
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(value)) {
|
||||
throw new Error(`invalid feature id: ${JSON.stringify(value)}`)
|
||||
}
|
||||
return value as FeatureId
|
||||
}
|
||||
|
||||
/** Stable identity of a resource contributed to an SDK project. */
|
||||
export type ResourceKey = Branded<'ResourceKey'>
|
||||
|
||||
/** Construct a resource key from its owner-qualified value. */
|
||||
export function resourceKey(value: string): ResourceKey {
|
||||
if (value.length === 0) throw new Error('resource key must not be empty')
|
||||
return value as ResourceKey
|
||||
}
|
||||
50
packages/scaffold/helper/src/index.ts
Normal file
50
packages/scaffold/helper/src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Shared domain and infrastructure for DeepSeek Harness SDK project tooling.
|
||||
*
|
||||
* FIXME: rename to `@deepseek-ai/dsh-sdk-helper` before the first tagged release —
|
||||
* the current name is indefensibly generic as a published name
|
||||
* ([regrouping Agent Note](../../../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md)).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper
|
||||
*/
|
||||
|
||||
export { featureId } from './ids.ts'
|
||||
export { TextTemplate } from './templates/text-template.ts'
|
||||
export type {
|
||||
FeatureSelection,
|
||||
ProjectCreationRequest,
|
||||
ProjectProfile,
|
||||
RunInterface,
|
||||
} from './project/types.ts'
|
||||
export type { ChangeSet, ProjectCommitResult } from './project/change-set.ts'
|
||||
export { SdkProject } from './project/sdk-project.ts'
|
||||
export {
|
||||
NodeCommandRunner,
|
||||
NpmPackageManager,
|
||||
createPackageManager,
|
||||
inferPackageManagerName,
|
||||
probePackageManagerVersion,
|
||||
} from './package-managers/package-manager.ts'
|
||||
export type {
|
||||
CommandRunner,
|
||||
PackageManager,
|
||||
PackageManagerName,
|
||||
PackageManagerVersionProbe,
|
||||
} from './package-managers/package-manager.ts'
|
||||
export { LocalPluginBlueprint } from './plugins/local-plugin-blueprint.ts'
|
||||
export type { LocalPluginKind } from './plugins/local-plugin-blueprint.ts'
|
||||
export type { Feature, FeatureInstallation } from './features/feature.ts'
|
||||
export type { FeatureRegistry } from './features/registry.ts'
|
||||
export { FeatureConfigurator } from './features/feature-configurator.ts'
|
||||
export { createBuiltinRegistry } from './features/builtin/index.ts'
|
||||
export { PromptCancelledError, requireAnswer } from './questions/prompt-port.ts'
|
||||
export type { NestedMultiSelectValue, PromptPort } from './questions/prompt-port.ts'
|
||||
export {
|
||||
ConfirmQuestion,
|
||||
SecretQuestion,
|
||||
SelectQuestion,
|
||||
TextQuestion,
|
||||
} from './questions/question.ts'
|
||||
export type { Question } from './questions/question.ts'
|
||||
export { ClackPromptPort } from './questions/clack-prompt-port.ts'
|
||||
export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'
|
||||
30
packages/scaffold/helper/src/invariant.ts
Normal file
30
packages/scaffold/helper/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-helper`.
|
||||
* @module @deepseek-ai/dsh-helper/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-helper'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'helper-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
137
packages/scaffold/helper/src/package-managers/link-workspace.ts
Normal file
137
packages/scaffold/helper/src/package-managers/link-workspace.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Repository package discovery and NPM dependency-closure rewriting for live links.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/package-managers/link-workspace
|
||||
*/
|
||||
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { existsSync, realpathSync } from 'node:fs'
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import type { PackageJsonFile, PackageManifest } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
import type { ProjectFile } from '../documents/project-file.ts'
|
||||
import type { PackageManager } from './package-manager.ts'
|
||||
|
||||
interface WorkspacePackage {
|
||||
directory: string
|
||||
manifest: PackageManifest
|
||||
}
|
||||
|
||||
function posixPath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function canonicalPath(path: string): string {
|
||||
let existing = resolve(path)
|
||||
const suffix: string[] = []
|
||||
while (!existsSync(existing)) {
|
||||
const parent = dirname(existing)
|
||||
/* v8 ignore next -- every absolute path reaches the existing filesystem root */
|
||||
if (parent === existing) throw new Error(`cannot resolve an existing ancestor for ${path}`)
|
||||
suffix.unshift(basename(existing))
|
||||
existing = parent
|
||||
}
|
||||
return resolve(realpathSync(existing), ...suffix)
|
||||
}
|
||||
|
||||
async function packageDirectories(root: string): Promise<string[]> {
|
||||
const result: string[] = []
|
||||
for (const vendor of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (vendor.isDirectory()) result.push(join(root, 'vendor', vendor.name))
|
||||
}
|
||||
for (const group of await readdir(join(root, 'packages'), { withFileTypes: true })) {
|
||||
if (!group.isDirectory()) continue
|
||||
for (const pkg of await readdir(join(root, 'packages', group.name), { withFileTypes: true })) {
|
||||
if (pkg.isDirectory()) result.push(join(root, 'packages', group.name, pkg.name))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Index of repository packages used by `--link-workspace`. */
|
||||
export class LinkWorkspace {
|
||||
readonly root: string
|
||||
private readonly packages: Map<string, WorkspacePackage>
|
||||
|
||||
private constructor(root: string, packages: Map<string, WorkspacePackage>) {
|
||||
this.root = root
|
||||
this.packages = packages
|
||||
}
|
||||
|
||||
/** Scan vendor and package workspaces from a repository root. */
|
||||
static async open(root: string): Promise<LinkWorkspace> {
|
||||
const absolute = resolve(root)
|
||||
const packages = new Map<string, WorkspacePackage>()
|
||||
for (const directory of await packageDirectories(absolute)) {
|
||||
let manifest: PackageManifest
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest
|
||||
} catch (error) {
|
||||
throw new Error(`cannot read linked package at ${directory}: ${String(error)}`)
|
||||
}
|
||||
if (!manifest.name || typeof manifest.name !== 'string') continue
|
||||
if (packages.has(manifest.name)) throw new Error(`duplicate linked package name: ${manifest.name}`)
|
||||
packages.set(manifest.name, { directory, manifest })
|
||||
}
|
||||
if (!packages.has('cordis') || !packages.has('@deepseek-ai/dsh-scripts')) {
|
||||
throw new Error(`not a DeepSeek Harness repository root: ${absolute}`)
|
||||
}
|
||||
return new LinkWorkspace(absolute, packages)
|
||||
}
|
||||
|
||||
/** Expand direct NPM dependencies through all repository-local NPM dependency edges. */
|
||||
closure(names: Iterable<string>): string[] {
|
||||
const pending = [...names]
|
||||
const result = new Set<string>()
|
||||
while (pending.length > 0) {
|
||||
const name = pending.pop()
|
||||
if (!name || result.has(name)) continue
|
||||
const pkg = this.packages.get(name)
|
||||
/* v8 ignore next -- closure() only returns names present in this package map */
|
||||
if (!pkg) continue
|
||||
result.add(name)
|
||||
const edges = {
|
||||
...pkg.manifest.dependencies,
|
||||
...pkg.manifest.peerDependencies as Record<string, string> | undefined,
|
||||
}
|
||||
for (const dependencyName of Object.keys(edges)) {
|
||||
if (this.packages.has(dependencyName) && !result.has(dependencyName)) pending.push(dependencyName)
|
||||
}
|
||||
}
|
||||
return [...result].sort()
|
||||
}
|
||||
|
||||
/** Rewrite the full local closure to manager-specific live-link specs. */
|
||||
apply(
|
||||
projectRoot: string,
|
||||
manifest: PackageJsonFile,
|
||||
manager: PackageManager,
|
||||
documents: readonly ProjectFile[],
|
||||
): void {
|
||||
const canonicalProjectRoot = canonicalPath(projectRoot)
|
||||
const names = this.closure(manifest.npmDependencyNames())
|
||||
for (const name of names) {
|
||||
const pkg = this.packages.get(name)
|
||||
/* v8 ignore next -- closure() only returns names present in this package map */
|
||||
if (!pkg) continue
|
||||
const relativePath = posixPath(relative(canonicalProjectRoot, realpathSync(pkg.directory)))
|
||||
const spec = manager.linkSpec(relativePath)
|
||||
const current = manifest.npmDependency(name)
|
||||
manifest.setNpmDependency(current?.section ?? 'dependencies', name, spec)
|
||||
if (manager.name === 'yarn') manifest.setResolution(name, spec)
|
||||
}
|
||||
if (manager.name === 'pnpm') {
|
||||
const workspace = documents.find((item): item is PnpmWorkspaceFile => item instanceof PnpmWorkspaceFile)
|
||||
if (!workspace) throw new Error('pnpm link mode requires pnpm-workspace.yaml')
|
||||
workspace.disableAutoInstallPeers()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a package directory for diagnostics and tests. */
|
||||
packageDirectory(name: string): string | undefined {
|
||||
const directory = this.packages.get(name)?.directory
|
||||
return directory
|
||||
? resolve(dirname(directory), directory.split(sep).at(-1) as string)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
332
packages/scaffold/helper/src/package-managers/package-manager.ts
Normal file
332
packages/scaffold/helper/src/package-managers/package-manager.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Package-manager strategies for SDK project workspaces and child commands.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/package-managers/package-manager
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import { scrubbedParentEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
|
||||
import { promisify } from 'node:util'
|
||||
import type { PackageJsonFile } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
import type { ProjectFile } from '../documents/project-file.ts'
|
||||
|
||||
/** Supported generated-project package managers. */
|
||||
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn'
|
||||
|
||||
/** Result from one child package-manager process. */
|
||||
export interface CommandResult {
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
}
|
||||
|
||||
/** Injectable subprocess boundary used by package-manager strategies. */
|
||||
export interface CommandRunner {
|
||||
/** Run one executable without a shell and await process exit. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult>
|
||||
}
|
||||
|
||||
/** Injectable package-manager version probe used by project creation. */
|
||||
export type PackageManagerVersionProbe = (name: PackageManagerName, cwd: string) => Promise<string>
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
/**
|
||||
* Read a manager version without forwarding ambient credentials.
|
||||
* @param name - package-manager executable.
|
||||
* @param cwd - working directory used for resolution.
|
||||
* @returns trimmed version output.
|
||||
*/
|
||||
export async function probePackageManagerVersion(name: PackageManagerName, cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(name, ['--version'], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const version = stdout.trim()
|
||||
if (!version) throw new Error('empty version output')
|
||||
return version
|
||||
} catch (error) {
|
||||
throw new Error(`cannot run ${name} --version: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credential-shaped environment variables from spawned commands.
|
||||
* @param environment - source environment (injectable for tests); the default
|
||||
* path shares the subprocess seam's scrub so every harness spawner drops the
|
||||
* same names.
|
||||
*/
|
||||
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (environment === undefined) return scrubbedParentEnv()
|
||||
return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name)))
|
||||
}
|
||||
|
||||
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
||||
export class NodeCommandRunner implements CommandRunner {
|
||||
private readonly output: NodeJS.WritableStream | undefined
|
||||
|
||||
/**
|
||||
* @param output - redirect target for child stdout+stderr; the child inherits
|
||||
* this process's stdio when absent. Callers whose own stdout carries a machine
|
||||
* protocol (create-sdk --json NDJSON) redirect child output to keep the
|
||||
* protocol stream pure.
|
||||
*/
|
||||
constructor(output?: NodeJS.WritableStream) {
|
||||
this.output = output
|
||||
}
|
||||
|
||||
/** Spawn one child and settle only after exit, with redirected stdio drained. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = this.output
|
||||
if (output === undefined) {
|
||||
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
return
|
||||
}
|
||||
const child = spawn(command, [...args], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
})
|
||||
child.stdout.pipe(output, { end: false })
|
||||
child.stderr.pipe(output, { end: false })
|
||||
child.once('error', reject)
|
||||
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function major(version: string): number {
|
||||
const match = /^(\d+)/.exec(version)
|
||||
if (!match?.[1]) throw new Error(`invalid package manager version: ${JSON.stringify(version)}`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
/** Behavior owned by one generated-project package manager. */
|
||||
export abstract class PackageManager {
|
||||
/** Manager executable and project identity. */
|
||||
abstract readonly name: PackageManagerName
|
||||
|
||||
/** Detected concrete manager version. */
|
||||
readonly version: string
|
||||
|
||||
constructor(version: string) {
|
||||
this.version = version
|
||||
}
|
||||
|
||||
/** Validate the detected version against this SDK's supported floor. */
|
||||
abstract validateVersion(): void
|
||||
|
||||
/**
|
||||
* Configure root manifest fields and return manager-specific files.
|
||||
* @param manifest - generated root manifest to update.
|
||||
* @returns manager-specific companion documents.
|
||||
*/
|
||||
abstract configureWorkspace(manifest: PackageJsonFile): ProjectFile[]
|
||||
|
||||
/**
|
||||
* Build the NPM dependency spec for a local workspace plugin.
|
||||
* @returns manager-specific local NPM dependency spec.
|
||||
*/
|
||||
abstract localPluginSpec(): string
|
||||
|
||||
/**
|
||||
* Resolve a repository live-link NPM dependency.
|
||||
* @param relativePath - relative path from generated project to package.
|
||||
* @returns manager-specific NPM dependency spec.
|
||||
*/
|
||||
abstract linkSpec(relativePath: string): string
|
||||
|
||||
/**
|
||||
* Build install command arguments.
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
installCommand(): readonly string[] {
|
||||
return ['install']
|
||||
}
|
||||
|
||||
/**
|
||||
* Build project-build command arguments.
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
buildCommand(): readonly string[] {
|
||||
return ['run', 'build']
|
||||
}
|
||||
|
||||
/**
|
||||
* Run NPM dependency installation and fail on non-zero or signalled exit.
|
||||
* @param cwd - generated project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async install(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.installCommand(), cwd, 'install')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the project build and fail on non-zero or signalled exit.
|
||||
* @param cwd - generated project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async build(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build add-dependency command arguments for one already-normalized source spec.
|
||||
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
addCommand(spec: string): readonly string[] {
|
||||
return ['add', spec]
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
|
||||
* @param spec - a package-manager-native dependency source.
|
||||
* @param cwd - project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
|
||||
}
|
||||
|
||||
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
|
||||
const result = await runner.run(this.name, args, cwd)
|
||||
if (result.signal !== null) {
|
||||
throw new Error(`${this.name} ${operation} was killed by ${result.signal}`)
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${this.name} ${operation} exited with code ${String(result.exitCode)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** npm workspace behavior. */
|
||||
export class NpmPackageManager extends PackageManager {
|
||||
override readonly name = 'npm'
|
||||
|
||||
/** npm 10 is the supported floor at the repository's Node floor. */
|
||||
override validateVersion(): void {
|
||||
if (major(this.version) < 10) throw new Error(`npm >=10 is required, got ${this.version}`)
|
||||
}
|
||||
|
||||
/** Configure package.json workspaces; npm needs no companion file. */
|
||||
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.setPackageManager(undefined)
|
||||
return []
|
||||
}
|
||||
|
||||
/** npm resolves workspace packages through its ordinary wildcard. */
|
||||
override localPluginSpec(): string {
|
||||
return '*'
|
||||
}
|
||||
|
||||
/** npm live links use file NPM dependencies. */
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `file:${relativePath}`
|
||||
}
|
||||
|
||||
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
|
||||
override addCommand(spec: string): readonly string[] {
|
||||
return ['install', spec]
|
||||
}
|
||||
}
|
||||
|
||||
/** pnpm workspace behavior. */
|
||||
export class PnpmPackageManager extends PackageManager {
|
||||
override readonly name = 'pnpm'
|
||||
|
||||
/** pnpm 10 is the supported floor for strict NPM dependency-build policy. */
|
||||
override validateVersion(): void {
|
||||
if (major(this.version) < 10) throw new Error(`pnpm >=10 is required, got ${this.version}`)
|
||||
}
|
||||
|
||||
/** Configure packageManager and a structured pnpm workspace file. */
|
||||
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
||||
manifest.setPackageManager(`pnpm@${this.version}`)
|
||||
const workspace = PnpmWorkspaceFile.create()
|
||||
workspace.addPackage('plugins/*')
|
||||
return [workspace]
|
||||
}
|
||||
|
||||
/** pnpm uses its explicit workspace protocol. */
|
||||
override localPluginSpec(): string {
|
||||
return 'workspace:*'
|
||||
}
|
||||
|
||||
/** pnpm live links use link NPM dependencies. */
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `link:${relativePath}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Yarn Berry-compatible workspace behavior. */
|
||||
export class YarnPackageManager extends PackageManager {
|
||||
override readonly name = 'yarn'
|
||||
|
||||
/** Yarn classic is excluded because the generated project relies on modern workspaces. */
|
||||
override validateVersion(): void {
|
||||
if (major(this.version) < 2) throw new Error(`Yarn >=2 is required, got ${this.version}`)
|
||||
}
|
||||
|
||||
/** Configure packageManager and package.json workspaces. */
|
||||
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.setPackageManager(`yarn@${this.version}`)
|
||||
return []
|
||||
}
|
||||
|
||||
/** Modern Yarn uses the workspace protocol. */
|
||||
override localPluginSpec(): string {
|
||||
return 'workspace:*'
|
||||
}
|
||||
|
||||
/** Yarn live links use portal NPM dependencies to preserve package identity. */
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `portal:${relativePath}`
|
||||
}
|
||||
|
||||
/** Yarn runs scripts without the `run` token. */
|
||||
override buildCommand(): readonly string[] {
|
||||
return ['build']
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and validate one package-manager strategy.
|
||||
* @param name - selected manager.
|
||||
* @param version - detected concrete version.
|
||||
* @returns validated strategy.
|
||||
*/
|
||||
export function createPackageManager(name: PackageManagerName, version: string): PackageManager {
|
||||
let manager: PackageManager
|
||||
switch (name) {
|
||||
case 'npm': manager = new NpmPackageManager(version); break
|
||||
case 'pnpm': manager = new PnpmPackageManager(version); break
|
||||
case 'yarn': manager = new YarnPackageManager(version); break
|
||||
}
|
||||
manager.validateVersion()
|
||||
return manager
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a package manager from an explicit choice or npm user-agent value.
|
||||
* @param explicit - explicit CLI selection.
|
||||
* @param userAgent - npm-compatible user-agent string.
|
||||
* @returns selected or inferred manager name.
|
||||
*/
|
||||
export function inferPackageManagerName(
|
||||
explicit: PackageManagerName | undefined,
|
||||
userAgent: string | undefined = process.env.npm_config_user_agent,
|
||||
): PackageManagerName | undefined {
|
||||
if (explicit) return explicit
|
||||
const token = userAgent?.split(' ')[0]?.split('/')[0]
|
||||
if (token === 'npm' || token === 'pnpm' || token === 'yarn') return token
|
||||
return undefined
|
||||
}
|
||||
123
packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts
Normal file
123
packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Source blueprints for local Cordis plugins generated under `plugins/*`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/plugins/local-plugin-blueprint
|
||||
*/
|
||||
|
||||
import { TextProjectFile } from '../documents/project-file.ts'
|
||||
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import { resolveNpmDependency } from '../project/npm-dependency-policy.ts'
|
||||
import { loadHelperTemplate } from '../templates/template-assets.ts'
|
||||
|
||||
/** Supported generated local-plugin shapes. */
|
||||
export type LocalPluginKind = 'plugin' | 'tool'
|
||||
|
||||
function kebab(value: string): string {
|
||||
const result = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
if (!result || !/^[a-z]/.test(result)) throw new Error(`invalid local plugin name: ${JSON.stringify(value)}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function packageName(projectName: string, pluginName: string): string {
|
||||
if (projectName.startsWith('@')) {
|
||||
const separator = projectName.indexOf('/')
|
||||
if (separator > 1 && separator < projectName.length - 1) {
|
||||
return `${projectName.slice(0, separator)}/${projectName.slice(separator + 1)}-${pluginName}`
|
||||
}
|
||||
}
|
||||
return `${projectName}-${pluginName}`
|
||||
}
|
||||
|
||||
interface LocalPluginTemplateContext {
|
||||
pluginName: string
|
||||
toolName: string
|
||||
toolTitle: string
|
||||
}
|
||||
|
||||
const PLUGIN_SOURCE = loadHelperTemplate<LocalPluginTemplateContext>('local-plugin.ts.tpl')
|
||||
const TOOL_SOURCE = loadHelperTemplate<LocalPluginTemplateContext>('local-tool.ts.tpl')
|
||||
const PLUGIN_TSDOWN = loadHelperTemplate<LocalPluginTemplateContext>('local-plugin-tsdown.config.ts.tpl')
|
||||
|
||||
/** One local plugin's derived package, source, build, and runtime entry. */
|
||||
export class LocalPluginBlueprint {
|
||||
/** Normalized local package and Cordis config entry name. */
|
||||
readonly name: string
|
||||
/** Generated plugin source shape. */
|
||||
readonly kind: LocalPluginKind
|
||||
|
||||
/** Normalize and validate one local plugin request. */
|
||||
constructor(name: string, kind: LocalPluginKind) {
|
||||
this.name = kebab(name)
|
||||
this.kind = kind
|
||||
}
|
||||
|
||||
/** Root-relative plugin directory. */
|
||||
get directory(): string {
|
||||
return `plugins/${this.name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive an npm package name from the root project identity.
|
||||
* @param projectName - generated root package name.
|
||||
* @returns local plugin package name.
|
||||
*/
|
||||
packageName(projectName: string): string {
|
||||
return packageName(projectName, this.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the runtime Cordis config entry for this local package.
|
||||
* @param projectName - generated root package name.
|
||||
* @returns Loader entry referencing the local package.
|
||||
*/
|
||||
cordisConfigEntry(projectName: string): CordisConfigEntry {
|
||||
return { id: this.name, name: this.packageName(projectName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete local package files.
|
||||
* @param projectName - generated root package name.
|
||||
* @param releaseVersion - SDK dependency version.
|
||||
* @returns local manifest, configs, and source documents.
|
||||
*/
|
||||
documents(projectName: string, releaseVersion: string): TextProjectFile[] {
|
||||
const name = this.packageName(projectName)
|
||||
const toolName = this.name.replaceAll('-', '_')
|
||||
const cordisSpec = resolveNpmDependency('cordis', 'devDependencies', releaseVersion).spec
|
||||
const manifest = {
|
||||
name,
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
type: 'module',
|
||||
main: 'lib/index.js',
|
||||
types: 'lib/index.d.ts',
|
||||
exports: { '.': { types: './lib/index.d.ts', default: './lib/index.js' } },
|
||||
peerDependencies: {
|
||||
...this.kind === 'tool' ? { '@deepseek-ai/dsh-tools': `^${releaseVersion}` } : {},
|
||||
cordis: cordisSpec,
|
||||
},
|
||||
devDependencies: {
|
||||
cordis: cordisSpec,
|
||||
},
|
||||
}
|
||||
const tsconfig = {
|
||||
extends: '../../tsconfig.base.json',
|
||||
compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
|
||||
include: ['src'],
|
||||
}
|
||||
const context: LocalPluginTemplateContext = {
|
||||
pluginName: this.name,
|
||||
toolName,
|
||||
toolTitle: toolName.replaceAll('_', ' '),
|
||||
}
|
||||
return [
|
||||
new TextProjectFile(`${this.directory}/package.json`, JSON.stringify(manifest, null, 2)),
|
||||
new TextProjectFile(`${this.directory}/tsconfig.json`, JSON.stringify(tsconfig, null, 2)),
|
||||
new TextProjectFile(`${this.directory}/tsdown.config.ts`, PLUGIN_TSDOWN.render(context)),
|
||||
new TextProjectFile(
|
||||
`${this.directory}/src/index.ts`,
|
||||
(this.kind === 'tool' ? TOOL_SOURCE : PLUGIN_SOURCE).render(context),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
26
packages/scaffold/helper/src/project/change-set.ts
Normal file
26
packages/scaffold/helper/src/project/change-set.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Result summary for one SDK project edit session.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/project/change-set
|
||||
*/
|
||||
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
|
||||
/** Immutable description of committed or pending project changes. */
|
||||
export interface ChangeSet {
|
||||
addedFeatures: readonly FeatureId[]
|
||||
enabledFeatures: readonly FeatureId[]
|
||||
disabledFeatures: readonly FeatureId[]
|
||||
configuredFeatures: readonly FeatureId[]
|
||||
addedPlugins: readonly string[]
|
||||
enabledPlugins: readonly string[]
|
||||
disabledPlugins: readonly string[]
|
||||
changedFiles: readonly string[]
|
||||
npmDependenciesChanged: boolean
|
||||
}
|
||||
|
||||
/** Result of committing one project edit session. */
|
||||
export interface ProjectCommitResult<TProject> {
|
||||
project: TProject
|
||||
changes: ChangeSet
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* NPM dependency baseline and version policy for generated SDK projects.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/project/npm-dependency-policy
|
||||
*/
|
||||
|
||||
import type { NpmDependencySection } from '../documents/package-json-file.ts'
|
||||
|
||||
/** One NPM dependency spec selected by the SDK release policy. */
|
||||
export interface ResolvedNpmDependency {
|
||||
section: NpmDependencySection
|
||||
spec: string
|
||||
}
|
||||
|
||||
/** NPM dependency maps rendered into a newly created root package.json. */
|
||||
export interface BaselineNpmDependencies {
|
||||
dependencies: Readonly<Record<string, string>>
|
||||
devDependencies: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly<Record<string, string>> = {
|
||||
'@cordisjs/plugin-hmr': '^1.0.15',
|
||||
'@cordisjs/plugin-timer': '^1.1.2',
|
||||
'@types/node': '^22.20.0',
|
||||
cordis: '^4.0.0-rc.7',
|
||||
tsdown: '0.22.2',
|
||||
tsx: '^4.22.4',
|
||||
typescript: '^6.0.3',
|
||||
}
|
||||
|
||||
const BASELINE_NPM_DEPENDENCY_NAMES: Readonly<Record<NpmDependencySection, readonly string[]>> = {
|
||||
dependencies: ['@deepseek-ai/dsh-scripts', 'cordis'],
|
||||
devDependencies: ['@types/node', 'tsdown', 'tsx', 'typescript'],
|
||||
}
|
||||
|
||||
/** Resolve one package to its generated-project section and version spec. */
|
||||
export function resolveNpmDependency(
|
||||
name: string,
|
||||
requestedSection: NpmDependencySection,
|
||||
releaseVersion: string,
|
||||
): ResolvedNpmDependency {
|
||||
if (name.startsWith('@deepseek-ai/dsh-')) {
|
||||
return { section: requestedSection, spec: `^${releaseVersion}` }
|
||||
}
|
||||
const spec = EXTERNAL_NPM_DEPENDENCY_SPECS[name]
|
||||
if (spec) return { section: requestedSection, spec }
|
||||
throw new Error(`no generated-project NPM dependency policy for ${name}`)
|
||||
}
|
||||
|
||||
/** Build the root package.json NPM dependency maps from the shared version policy. */
|
||||
export function baselineNpmDependencies(releaseVersion: string): BaselineNpmDependencies {
|
||||
return {
|
||||
dependencies: Object.fromEntries(BASELINE_NPM_DEPENDENCY_NAMES.dependencies.map((name) => {
|
||||
const dependency = resolveNpmDependency(name, 'dependencies', releaseVersion)
|
||||
return [name, dependency.spec]
|
||||
})),
|
||||
devDependencies: Object.fromEntries(BASELINE_NPM_DEPENDENCY_NAMES.devDependencies.map((name) => {
|
||||
const dependency = resolveNpmDependency(name, 'devDependencies', releaseVersion)
|
||||
return [name, dependency.spec]
|
||||
})),
|
||||
}
|
||||
}
|
||||
616
packages/scaffold/helper/src/project/project-edit-session.ts
Normal file
616
packages/scaffold/helper/src/project/project-edit-session.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Isolated domain-command and commit boundary for SDK project changes.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/project/project-edit-session
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import type {
|
||||
Feature,
|
||||
FeatureInstallation,
|
||||
FeatureProjectView,
|
||||
FeatureRequirement,
|
||||
} from '../features/feature.ts'
|
||||
import type { FeatureRegistry } from '../features/registry.ts'
|
||||
import type { ProjectResource } from '../features/resources.ts'
|
||||
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import { EnvFile } from '../documents/env-file.ts'
|
||||
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
|
||||
import { ProjectFile } from '../documents/project-file.ts'
|
||||
import { TsConfigFile } from '../documents/tsconfig-file.ts'
|
||||
import { featureId, type FeatureId, type ResourceKey } from '../ids.ts'
|
||||
import { LinkWorkspace } from '../package-managers/link-workspace.ts'
|
||||
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||
import type { FeatureSelection, ProjectProfile } from './types.ts'
|
||||
import { resolveNpmDependency } from './npm-dependency-policy.ts'
|
||||
import type { ChangeSet, ProjectCommitResult } from './change-set.ts'
|
||||
import type { SdkProject } from './sdk-project.ts'
|
||||
|
||||
interface MutableFeatureState {
|
||||
selection?: FeatureSelection
|
||||
state: FeatureInstallation['state']
|
||||
}
|
||||
|
||||
function sameText(left: ProjectFile, right: ProjectFile | undefined): boolean {
|
||||
return right !== undefined && left.serialize() === right.serialize()
|
||||
}
|
||||
|
||||
function npmDependencyShape(manifest: Readonly<PackageManifest>): string {
|
||||
return JSON.stringify({
|
||||
/* v8 ignore next -- generated manifests always carry the managed dependency maps */
|
||||
dependencies: manifest.dependencies ?? {},
|
||||
/* v8 ignore next -- generated manifests always carry the managed dependency maps */
|
||||
devDependencies: manifest.devDependencies ?? {},
|
||||
})
|
||||
}
|
||||
|
||||
function asError(error: unknown): Error {
|
||||
/* v8 ignore else -- node:fs promise APIs reject Error objects */
|
||||
if (error instanceof Error) return error
|
||||
/* v8 ignore next -- node:fs promise APIs reject Error objects */
|
||||
return new Error(String(error))
|
||||
}
|
||||
|
||||
function canUpdateResource(previous: ProjectResource, next: ProjectResource): boolean {
|
||||
if (previous.kind !== next.kind) return false
|
||||
switch (previous.kind) {
|
||||
case 'npm-dependency': return previous.name === (next as typeof previous).name
|
||||
case 'package-script': return previous.name === (next as typeof previous).name
|
||||
case 'cordis-config-entry': {
|
||||
const candidate = next as typeof previous
|
||||
return previous.entry.name === candidate.entry.name
|
||||
}
|
||||
case 'environment': return previous.name === (next as typeof previous).name
|
||||
case 'owned-file': return previous.document.relativePath === (next as typeof previous).document.relativePath
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable working copy that applies feature and local-plugin domain commands. */
|
||||
export class ProjectEditSession implements FeatureProjectView {
|
||||
readonly profile: ProjectProfile
|
||||
private readonly source: SdkProject
|
||||
private readonly registry: FeatureRegistry
|
||||
private readonly documents: Map<string, ProjectFile>
|
||||
private readonly removed = new Map<string, ProjectFile>()
|
||||
private readonly states = new Map<FeatureId, MutableFeatureState>()
|
||||
private readonly added = new Set<FeatureId>()
|
||||
private readonly enabled = new Set<FeatureId>()
|
||||
private readonly disabled = new Set<FeatureId>()
|
||||
private readonly configured = new Set<FeatureId>()
|
||||
private readonly addedPlugins = new Set<string>()
|
||||
private readonly enabledPlugins = new Set<string>()
|
||||
private readonly disabledPlugins = new Set<string>()
|
||||
private committed = false
|
||||
|
||||
/** Clone one project snapshot into an isolated working copy. */
|
||||
constructor(source: SdkProject, registry: FeatureRegistry) {
|
||||
this.source = source
|
||||
this.registry = registry
|
||||
this.profile = source.profile
|
||||
this.documents = source.cloneDocuments()
|
||||
for (const feature of registry.all()) {
|
||||
/* v8 ignore next -- no current built-in feature is interface-specific */
|
||||
if (!feature.isApplicable(this.profile)) continue
|
||||
const installation = feature.inspect(this)
|
||||
this.states.set(feature.id, {
|
||||
state: installation.state,
|
||||
...installation.selection ? { selection: installation.selection } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Root manifest value for feature inspection. */
|
||||
packageManifest(): Readonly<PackageManifest> {
|
||||
return this.manifest().value()
|
||||
}
|
||||
|
||||
/** Cordis config entries for feature and custom-plugin inspection. */
|
||||
cordisConfigEntries(): readonly CordisConfigEntry[] {
|
||||
return this.cordis().entries()
|
||||
}
|
||||
|
||||
/** Whether one managed document exists in the working copy. */
|
||||
/* jscpd:ignore-start -- FeatureProjectView deliberately has symmetric snapshot/edit implementations. */
|
||||
hasDocument(path: string): boolean {
|
||||
return this.documents.has(path)
|
||||
}
|
||||
|
||||
/** Read one unique working-copy environment variable. */
|
||||
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined {
|
||||
const document = this.documents.get(path)
|
||||
if (!document) return undefined
|
||||
if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`)
|
||||
return document.get(name)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Inspect every applicable builtin against the current working copy. */
|
||||
inspections(): readonly FeatureInstallation[] {
|
||||
return this.registry.inspect(this)
|
||||
}
|
||||
|
||||
/** Install a builtin and recursively satisfy its declared requirements. */
|
||||
installFeature(feature: Feature, selection: FeatureSelection): void {
|
||||
this.assertOpen()
|
||||
this.installFeatureRecursive(feature, selection, new Set())
|
||||
}
|
||||
|
||||
/** Replace one installed builtin's feature-option and captured-input selection. */
|
||||
configureFeature(feature: Feature, selection: FeatureSelection): void {
|
||||
this.assertOpen()
|
||||
const current = this.state(feature)
|
||||
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
|
||||
if (current.state === 'absent' || !current.selection) {
|
||||
this.installFeature(feature, selection)
|
||||
return
|
||||
}
|
||||
const normalized = feature.normalizeSelection(selection, this.profile)
|
||||
this.ensureRequirements(feature, normalized, new Set([feature.id]))
|
||||
this.replaceContribution(
|
||||
feature.contribution(current.selection, this.profile),
|
||||
feature.contribution(normalized, this.profile),
|
||||
)
|
||||
current.selection = normalized
|
||||
current.state = current.state === 'disabled' ? 'disabled' : 'enabled'
|
||||
if (current.state === 'disabled') this.setFeatureDisabled(feature, normalized, true)
|
||||
this.assertFeatureConsistent(feature)
|
||||
this.configured.add(feature.id)
|
||||
}
|
||||
|
||||
/** Enable all entries owned by one installed feature. */
|
||||
enableFeature(feature: Feature): void {
|
||||
this.assertOpen()
|
||||
const current = this.state(feature)
|
||||
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
|
||||
if (current.state === 'absent' || !current.selection) {
|
||||
throw new Error(`feature ${feature.id} is not installed`)
|
||||
}
|
||||
this.setFeatureDisabled(feature, current.selection, false)
|
||||
current.state = 'enabled'
|
||||
this.assertFeatureConsistent(feature)
|
||||
this.disabled.delete(feature.id)
|
||||
this.enabled.add(feature.id)
|
||||
}
|
||||
|
||||
/** Disable an optional feature without removing its configuration. */
|
||||
disableFeature(feature: Feature): void {
|
||||
this.assertOpen()
|
||||
if (feature.required) throw new Error(`required feature ${feature.id} cannot be disabled`)
|
||||
const current = this.state(feature)
|
||||
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
|
||||
if (current.state === 'absent' || !current.selection) {
|
||||
throw new Error(`feature ${feature.id} is not installed`)
|
||||
}
|
||||
const dependent = this.registry.all().find((candidate) => {
|
||||
const state = this.states.get(candidate.id)
|
||||
return state?.state === 'enabled' && state.selection
|
||||
&& candidate.requirements(state.selection).some(requirement => requirement.id === feature.id)
|
||||
})
|
||||
if (dependent) throw new Error(`feature ${feature.id} is required by ${dependent.id}`)
|
||||
this.setFeatureDisabled(feature, current.selection, true)
|
||||
current.state = 'disabled'
|
||||
this.assertFeatureConsistent(feature)
|
||||
this.enabled.delete(feature.id)
|
||||
this.disabled.add(feature.id)
|
||||
}
|
||||
|
||||
/** Add a generated local plugin and all four of its project registrations. */
|
||||
addPlugin(blueprint: LocalPluginBlueprint): void {
|
||||
this.assertOpen()
|
||||
const manifest = this.manifest()
|
||||
const cordis = this.cordis()
|
||||
const tsconfig = this.documents.get('tsconfig.json')
|
||||
if (!(tsconfig instanceof TsConfigFile)) {
|
||||
throw new Error('adding a local plugin requires a valid tsconfig.json')
|
||||
}
|
||||
const packageName = blueprint.packageName(this.profile.name)
|
||||
if (manifest.npmDependency(packageName)) throw new Error(`root NPM dependency already exists: ${packageName}`)
|
||||
const entry = blueprint.cordisConfigEntry(this.profile.name)
|
||||
if (cordis.entry(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`)
|
||||
const documents = blueprint.documents(this.profile.name, this.profile.releaseVersion)
|
||||
for (const document of documents) {
|
||||
if (this.documents.has(document.relativePath)) {
|
||||
throw new Error(`local plugin file already exists: ${document.relativePath}`)
|
||||
}
|
||||
}
|
||||
for (const document of documents) this.documents.set(document.relativePath, document)
|
||||
manifest.setNpmDependency('dependencies', packageName, this.profile.packageManager.localPluginSpec())
|
||||
tsconfig.addReference(`./${blueprint.directory}`)
|
||||
cordis.addEntry(entry)
|
||||
this.addedPlugins.add(entry.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a Cordis entry for an external dependency the package manager has already
|
||||
* added (github or npm), without generating files or re-adding the dependency.
|
||||
* @param id - stable Cordis config entry id.
|
||||
* @param packageName - the installed dependency's package name.
|
||||
*/
|
||||
addExternalPlugin(id: string, packageName: string): void {
|
||||
this.assertOpen()
|
||||
if (!this.manifest().npmDependency(packageName)) {
|
||||
throw new Error(`external plugin dependency is not installed: ${packageName}`)
|
||||
}
|
||||
const cordis = this.cordis()
|
||||
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
|
||||
cordis.addEntry({ id, name: packageName })
|
||||
this.addedPlugins.add(id)
|
||||
}
|
||||
|
||||
/** Enable or disable one custom/manual Cordis config entry by stable id. */
|
||||
setCustomPluginDisabled(id: string, disabled: boolean): void {
|
||||
this.assertOpen()
|
||||
const entry = this.cordis().entry(id)
|
||||
if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`)
|
||||
if (this.registry.ownerOfPackage(entry.name, this.profile)) {
|
||||
throw new Error(`Cordis config entry ${id} belongs to a builtin feature`)
|
||||
}
|
||||
this.cordis().setDisabled(id, disabled)
|
||||
if (disabled) {
|
||||
this.enabledPlugins.delete(id)
|
||||
this.disabledPlugins.add(id)
|
||||
} else {
|
||||
this.disabledPlugins.delete(id)
|
||||
this.enabledPlugins.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Summarize all pending domain and file changes. */
|
||||
changes(): ChangeSet {
|
||||
const changedFiles = new Set<string>()
|
||||
for (const [path, document] of this.documents) {
|
||||
if (this.source.origin === 'create' || !sameText(document, this.source.document(path))) changedFiles.add(path)
|
||||
}
|
||||
for (const path of this.removed.keys()) changedFiles.add(path)
|
||||
return {
|
||||
addedFeatures: [...this.added].sort(),
|
||||
enabledFeatures: [...this.enabled].sort(),
|
||||
disabledFeatures: [...this.disabled].sort(),
|
||||
configuredFeatures: [...this.configured].sort(),
|
||||
addedPlugins: [...this.addedPlugins].sort(),
|
||||
enabledPlugins: [...this.enabledPlugins].sort(),
|
||||
disabledPlugins: [...this.disabledPlugins].sort(),
|
||||
changedFiles: [...changedFiles].sort(),
|
||||
npmDependenciesChanged: npmDependencyShape(this.manifest().value())
|
||||
!== npmDependencyShape(this.source.packageManifest()),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate, detect external edits, write affected files, and return a fresh snapshot. */
|
||||
async commit(): Promise<ProjectCommitResult<SdkProject>> {
|
||||
this.assertOpen()
|
||||
if (this.profile.linkWorkspaceRoot) {
|
||||
const workspace = await LinkWorkspace.open(this.profile.linkWorkspaceRoot)
|
||||
workspace.apply(
|
||||
this.source.root,
|
||||
this.manifest(),
|
||||
this.profile.packageManager,
|
||||
[...this.documents.values()],
|
||||
)
|
||||
}
|
||||
this.validateFinalState()
|
||||
const changes = this.changes()
|
||||
await this.assertUnchanged(changes.changedFiles)
|
||||
await mkdir(this.source.root, { recursive: true })
|
||||
for (const path of changes.changedFiles) {
|
||||
const document = this.documents.get(path)
|
||||
const absolute = resolve(this.source.root, path)
|
||||
if (!document) {
|
||||
await unlink(absolute)
|
||||
continue
|
||||
}
|
||||
await mkdir(dirname(absolute), { recursive: true })
|
||||
await writeFile(absolute, document.serialize(), {
|
||||
encoding: 'utf8',
|
||||
...document.createMode === undefined ? {} : { mode: document.createMode },
|
||||
})
|
||||
}
|
||||
this.committed = true
|
||||
return { project: await this.source.reopen(), changes }
|
||||
}
|
||||
|
||||
private installFeatureRecursive(
|
||||
feature: Feature,
|
||||
selection: FeatureSelection,
|
||||
stack: Set<FeatureId>,
|
||||
): void {
|
||||
if (stack.has(feature.id)) throw new Error(`cyclic feature requirement involving ${feature.id}`)
|
||||
const current = this.state(feature)
|
||||
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
|
||||
if (current.state !== 'absent' && current.selection) {
|
||||
this.configureFeature(feature, selection)
|
||||
if (current.state === 'disabled') this.enableFeature(feature)
|
||||
return
|
||||
}
|
||||
const normalized = feature.normalizeSelection(selection, this.profile)
|
||||
const nextStack = new Set(stack).add(feature.id)
|
||||
this.ensureRequirements(feature, normalized, nextStack)
|
||||
this.replaceContribution(undefined, feature.contribution(normalized, this.profile))
|
||||
current.selection = normalized
|
||||
current.state = 'enabled'
|
||||
this.assertFeatureConsistent(feature)
|
||||
this.added.add(feature.id)
|
||||
}
|
||||
|
||||
private ensureRequirements(feature: Feature, selection: FeatureSelection, stack: Set<FeatureId>): void {
|
||||
for (const requirement of feature.requirements(selection)) {
|
||||
const required = this.registry.get(requirement.id)
|
||||
const state = this.state(required)
|
||||
if (state.state === 'inconsistent') throw new Error(`required feature ${required.id} is inconsistent`)
|
||||
if (state.state === 'absent' || !state.selection) {
|
||||
this.installFeatureRecursive(required, {
|
||||
id: required.id,
|
||||
options: requirement.options ?? required.defaultOptions(this.profile),
|
||||
}, stack)
|
||||
} else {
|
||||
const next = this.selectionWithRequiredOptions(required, state.selection, requirement)
|
||||
if (next !== state.selection) this.configureFeature(required, next)
|
||||
if (state.state === 'disabled') this.enableFeature(required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private selectionWithRequiredOptions(
|
||||
feature: Feature,
|
||||
selection: FeatureSelection,
|
||||
requirement: FeatureRequirement,
|
||||
): FeatureSelection {
|
||||
if (!requirement.options || requirement.options.every(option => selection.options.includes(option))) {
|
||||
return selection
|
||||
}
|
||||
if (feature.mode !== 'multiple') {
|
||||
throw new Error(`${feature.id} does not satisfy the option requirement from another feature`)
|
||||
}
|
||||
return { ...selection, options: [...new Set([...selection.options, ...requirement.options])] }
|
||||
}
|
||||
|
||||
private replaceContribution(
|
||||
previous: ReturnType<Feature['contribution']> | undefined,
|
||||
next: ReturnType<Feature['contribution']>,
|
||||
): void {
|
||||
const previousByKey = previous?.byKey() ?? new Map<ResourceKey, ProjectResource>()
|
||||
const nextByKey = next.byKey()
|
||||
for (const [key, resource] of previousByKey) {
|
||||
const replacement = nextByKey.get(key)
|
||||
if (!replacement || !canUpdateResource(resource, replacement)) this.removeResource(resource)
|
||||
}
|
||||
for (const [key, resource] of nextByKey) {
|
||||
const previousResource = previousByKey.get(key)
|
||||
this.applyResource(
|
||||
resource,
|
||||
previousResource && canUpdateResource(previousResource, resource) ? previousResource : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void {
|
||||
switch (resource.kind) {
|
||||
case 'npm-dependency': {
|
||||
const dependency = resolveNpmDependency(resource.name, resource.section, this.profile.releaseVersion)
|
||||
this.manifest().setNpmDependency(dependency.section, resource.name, dependency.spec)
|
||||
return
|
||||
}
|
||||
case 'package-script': {
|
||||
const manifest = this.manifest()
|
||||
const current = manifest.script(resource.name)
|
||||
if (!previous || previous.kind !== 'package-script') {
|
||||
if (current !== undefined) throw new Error(`feature-owned package script already exists: ${resource.name}`)
|
||||
manifest.setScript(resource.name, resource.command)
|
||||
return
|
||||
}
|
||||
if (current === resource.command) return
|
||||
if (current !== previous.command) {
|
||||
throw new Error(`feature-owned package script was modified: ${resource.name}`)
|
||||
}
|
||||
manifest.setScript(resource.name, resource.command)
|
||||
return
|
||||
}
|
||||
case 'cordis-config-entry': {
|
||||
const current = this.cordis().entry(resource.entry.id)
|
||||
if (!current) this.cordis().addEntry(resource.entry, resource.commentedExample)
|
||||
else {
|
||||
if (current.name !== resource.entry.name) {
|
||||
throw new Error(`Cordis config entry ${resource.entry.id} is owned by ${current.name}, not ${resource.entry.name}`)
|
||||
}
|
||||
this.cordis().updateOwnedConfig(
|
||||
resource.entry.id,
|
||||
resource.ownedConfigKeys,
|
||||
resource.entry.config ?? {},
|
||||
)
|
||||
this.cordis().setDisabled(resource.entry.id, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'environment': {
|
||||
this.environment('.env.example').set(resource.name, resource.exampleValue)
|
||||
/* v8 ignore else -- an omitted secret intentionally materializes only its example placeholder */
|
||||
if (resource.value !== undefined) {
|
||||
const environment = this.environment('.env')
|
||||
environment.append(
|
||||
resource.name,
|
||||
resource.value,
|
||||
resource.value === '' ? resource.comment : undefined,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'owned-file': {
|
||||
const existing = this.documents.get(resource.document.relativePath)
|
||||
if (!existing) {
|
||||
this.documents.set(resource.document.relativePath, resource.document.clone())
|
||||
this.removed.delete(resource.document.relativePath)
|
||||
return
|
||||
}
|
||||
if (!previous || previous.kind !== 'owned-file') {
|
||||
throw new Error(`feature-owned file already exists: ${resource.document.relativePath}`)
|
||||
}
|
||||
if (previous.document.serialize() === resource.document.serialize()) return
|
||||
if (existing.serialize() !== previous.document.serialize()) {
|
||||
throw new Error(`feature-owned file was modified: ${resource.document.relativePath}`)
|
||||
}
|
||||
this.documents.set(resource.document.relativePath, resource.document.clone())
|
||||
this.removed.delete(resource.document.relativePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeResource(resource: ProjectResource): void {
|
||||
switch (resource.kind) {
|
||||
case 'npm-dependency':
|
||||
this.manifest().removeNpmDependency(resource.section, resource.name)
|
||||
return
|
||||
case 'package-script': {
|
||||
const manifest = this.manifest()
|
||||
const current = manifest.script(resource.name)
|
||||
if (current === undefined) throw new Error(`owned package script is missing: ${resource.name}`)
|
||||
if (resource.removeOnlyWhenUnchanged && current !== resource.command) {
|
||||
throw new Error(`feature-owned package script was modified: ${resource.name}`)
|
||||
}
|
||||
manifest.removeScript(resource.name)
|
||||
return
|
||||
}
|
||||
case 'cordis-config-entry': {
|
||||
const entry = this.cordis().entry(resource.entry.id)
|
||||
if (!entry || entry.name !== resource.entry.name) {
|
||||
throw new Error(`cannot confirm old Cordis resource ${resource.entry.id}`)
|
||||
}
|
||||
this.cordis().removeEntry(resource.entry.id)
|
||||
return
|
||||
}
|
||||
case 'environment':
|
||||
this.environment('.env.example').remove(resource.name)
|
||||
return
|
||||
case 'owned-file': {
|
||||
const document = this.documents.get(resource.document.relativePath)
|
||||
if (!document) throw new Error(`owned file is missing: ${resource.document.relativePath}`)
|
||||
if (resource.removeOnlyWhenUnchanged && document.serialize() !== resource.document.serialize()) {
|
||||
throw new Error(`owned file was modified: ${resource.document.relativePath}`)
|
||||
}
|
||||
this.documents.delete(resource.document.relativePath)
|
||||
if (this.source.document(resource.document.relativePath)) {
|
||||
this.removed.set(resource.document.relativePath, document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setFeatureDisabled(feature: Feature, selection: FeatureSelection, disabled: boolean): void {
|
||||
for (const resource of feature.contribution(selection, this.profile).resources) {
|
||||
if (resource.kind === 'cordis-config-entry') this.cordis().setDisabled(resource.entry.id, disabled)
|
||||
}
|
||||
}
|
||||
|
||||
private validateFinalState(): void {
|
||||
for (const document of this.documents.values()) document.validate()
|
||||
const profile = this.finalProfile()
|
||||
const view = this.projectView(profile)
|
||||
for (const feature of this.registry.all()) {
|
||||
const state = this.states.get(feature.id)
|
||||
/* v8 ignore next 5 -- no current built-in feature is interface-specific */
|
||||
if (!feature.isApplicable(profile)) {
|
||||
if (state?.state === 'enabled') {
|
||||
throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const installation = feature.inspect(view)
|
||||
/* v8 ignore next 3 -- public domain commands assert feature consistency before final validation */
|
||||
if (installation.state === 'inconsistent') {
|
||||
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
|
||||
}
|
||||
/* v8 ignore next 3 -- required features are installed by creation and cannot be disabled by public commands */
|
||||
if (feature.required && installation.state !== 'enabled') {
|
||||
throw new Error(`required feature ${feature.id} must be installed and enabled`)
|
||||
}
|
||||
if (installation.state !== 'enabled' || !installation.selection) continue
|
||||
for (const requirement of feature.requirements(installation.selection)) {
|
||||
const required = this.registry.get(requirement.id).inspect(view)
|
||||
/* v8 ignore next 3 -- ensureRequirements establishes enabled requirements before contributions change */
|
||||
if (required.state !== 'enabled') {
|
||||
throw new Error(`feature ${feature.id} requires enabled ${requirement.id}`)
|
||||
}
|
||||
for (const option of requirement.options ?? []) {
|
||||
/* v8 ignore next 3 -- selectionWithRequiredOptions establishes required options before commit */
|
||||
if (!required.options.includes(option)) {
|
||||
throw new Error(`feature ${feature.id} requires ${requirement.id} option ${option}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertFeatureConsistent(feature: Feature): void {
|
||||
const installation = feature.inspect(this)
|
||||
/* v8 ignore next 3 -- resource application either succeeds completely or throws at the owning operation */
|
||||
if (installation.state === 'inconsistent') {
|
||||
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
private finalProfile(): ProjectProfile {
|
||||
const runInterface = this.states.get(featureId('app'))?.selection?.options[0]
|
||||
if (runInterface !== 'acp' && runInterface !== 'embed') return this.profile
|
||||
return { ...this.profile, runInterface }
|
||||
}
|
||||
|
||||
private projectView(profile: ProjectProfile): FeatureProjectView {
|
||||
return {
|
||||
profile,
|
||||
cordisConfigEntries: () => this.cordisConfigEntries(),
|
||||
packageManifest: () => this.packageManifest(),
|
||||
hasDocument: path => this.hasDocument(path),
|
||||
readEnvironment: (path, name) => this.readEnvironment(path, name),
|
||||
}
|
||||
}
|
||||
|
||||
private async assertUnchanged(paths: readonly string[]): Promise<void> {
|
||||
for (const path of paths) {
|
||||
const source = this.source.document(path)
|
||||
const absolute = resolve(this.source.root, path)
|
||||
try {
|
||||
const current = await readFile(absolute, 'utf8')
|
||||
if (source?.originalText === undefined || current !== source.originalText) {
|
||||
throw new Error(`project file changed outside this edit session: ${path}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT' && source?.originalText === undefined) continue
|
||||
if (error instanceof Error && error.message.startsWith('project file changed outside')) throw error
|
||||
throw new Error(`cannot verify project file ${path}: ${asError(error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private state(feature: Feature): MutableFeatureState {
|
||||
const state = this.states.get(feature.id)
|
||||
if (!state) throw new Error(`feature ${feature.id} is not applicable to this project`)
|
||||
return state
|
||||
}
|
||||
|
||||
private manifest(): PackageJsonFile {
|
||||
const document = this.documents.get('package.json')
|
||||
if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing')
|
||||
return document
|
||||
}
|
||||
|
||||
private cordis(): CordisYamlFile {
|
||||
const document = this.documents.get('cordis.yml')
|
||||
if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing')
|
||||
return document
|
||||
}
|
||||
|
||||
private environment(path: '.env' | '.env.example'): EnvFile {
|
||||
const existing = this.documents.get(path)
|
||||
if (existing instanceof EnvFile) return existing
|
||||
if (existing) throw new Error(`${path} is not an environment document`)
|
||||
const document = EnvFile.create(path)
|
||||
this.documents.set(path, document)
|
||||
return document
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.committed) throw new Error('project edit session has already committed')
|
||||
}
|
||||
}
|
||||
309
packages/scaffold/helper/src/project/sdk-project.ts
Normal file
309
packages/scaffold/helper/src/project/sdk-project.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Read-only aggregate for one generated or existing SDK project.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/project/sdk-project
|
||||
*/
|
||||
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
|
||||
import { EnvFile } from '../documents/env-file.ts'
|
||||
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
||||
import { ProjectFile, TextProjectFile } from '../documents/project-file.ts'
|
||||
import { TsConfigFile } from '../documents/tsconfig-file.ts'
|
||||
import {
|
||||
createPackageManager,
|
||||
type PackageManager,
|
||||
type PackageManagerName,
|
||||
} from '../package-managers/package-manager.ts'
|
||||
import {
|
||||
createBaselineProjectArtifacts,
|
||||
createPackageJsonDoc,
|
||||
createProjectTemplateContext,
|
||||
} from '../templates/project-template.ts'
|
||||
import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts'
|
||||
import type { FeatureRegistry } from '../features/registry.ts'
|
||||
import { ProjectEditSession } from './project-edit-session.ts'
|
||||
|
||||
/** Whether a project snapshot describes uncommitted creation or files on disk. */
|
||||
export type ProjectOrigin = 'create' | 'disk'
|
||||
|
||||
const OPTIONAL_DOCUMENTS = [
|
||||
'.env',
|
||||
'.env.example',
|
||||
'tsconfig.json',
|
||||
'pnpm-workspace.yaml',
|
||||
'hooks.json',
|
||||
'codex-hooks.json',
|
||||
'README.md',
|
||||
'index.ts',
|
||||
] as const
|
||||
|
||||
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui'
|
||||
|| entry.name.startsWith('@deepseek-ai/dsh-tui/'))) {
|
||||
throw new Error('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
}
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
|
||||
return 'embed'
|
||||
}
|
||||
|
||||
function runtimeModel(entries: readonly CordisConfigEntry[]): string {
|
||||
const acp = entries.find(entry => entry.name === '@deepseek-ai/dsh-acp')
|
||||
if (typeof acp?.config?.model === 'string' && acp.config.model.length > 0) return acp.config.model
|
||||
const provider = entries.find(entry => entry.name === '@deepseek-ai/dsh-llm-deepseek'
|
||||
|| entry.name === '@deepseek-ai/dsh-llm-pi-ai')
|
||||
const models = provider?.config?.models
|
||||
if (Array.isArray(models) && typeof models[0] === 'string') return models[0]
|
||||
return 'deepseek-v4-flash'
|
||||
}
|
||||
|
||||
function releaseVersion(manifest: Readonly<PackageManifest>): string {
|
||||
const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
|
||||
const match = spec && /(?:^|[^0-9])(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(spec)
|
||||
return match?.[1] ?? '0.0.1'
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
/* v8 ignore else -- the other arm requires a filesystem permission/IO fault from access */
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
/* v8 ignore next -- paired with the ignored defensive access-error arm above */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPackageManager(root: string, manifest: Readonly<PackageManifest>): Promise<PackageManager> {
|
||||
let name: PackageManagerName = 'npm'
|
||||
let version = '10.0.0'
|
||||
const field = manifest.packageManager
|
||||
if (field) {
|
||||
const match = /^(npm|pnpm|yarn)@(.+)$/.exec(field)
|
||||
if (!match?.[1] || !match[2]) throw new Error(`invalid packageManager field: ${field}`)
|
||||
name = match[1] as PackageManagerName
|
||||
version = match[2]
|
||||
} else if (await pathExists(resolve(root, 'pnpm-lock.yaml'))) {
|
||||
name = 'pnpm'
|
||||
version = '10.0.0'
|
||||
} else if (await pathExists(resolve(root, 'yarn.lock'))) {
|
||||
name = 'yarn'
|
||||
version = '2.0.0'
|
||||
}
|
||||
return createPackageManager(name, version)
|
||||
}
|
||||
|
||||
function linkedRepositoryRoot(root: string, manifest: Readonly<PackageManifest>): string | undefined {
|
||||
const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
|
||||
const match = /^(?:file|link|portal):(.+)\/packages\/scaffold\/scripts\/?$/.exec(spec ?? '')
|
||||
return match?.[1] ? resolve(root, match[1]) : undefined
|
||||
}
|
||||
|
||||
function parseOptionalDocument(path: string, text: string): ProjectFile {
|
||||
try {
|
||||
switch (path) {
|
||||
case '.env': return EnvFile.parse('.env', text)
|
||||
case '.env.example': return EnvFile.parse('.env.example', text)
|
||||
case 'tsconfig.json': return TsConfigFile.parse(text)
|
||||
case 'pnpm-workspace.yaml': return PnpmWorkspaceFile.parse(text)
|
||||
default: return new TextProjectFile(path, text, text)
|
||||
}
|
||||
} catch {
|
||||
// Optional malformed resources do not invalidate the project aggregate;
|
||||
// an operation that needs their structure checks the concrete document type.
|
||||
return new TextProjectFile(path, text, text)
|
||||
}
|
||||
}
|
||||
|
||||
/** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */
|
||||
export class SdkProject {
|
||||
/** Absolute project directory. */
|
||||
readonly root: string
|
||||
/** Whether this snapshot is an uncommitted blueprint or disk state. */
|
||||
readonly origin: ProjectOrigin
|
||||
/** Project identity, runtime, interface, and package-manager context. */
|
||||
readonly profile: ProjectProfile
|
||||
private readonly documents: ReadonlyMap<string, ProjectFile>
|
||||
|
||||
private constructor(
|
||||
root: string,
|
||||
origin: ProjectOrigin,
|
||||
profile: ProjectProfile,
|
||||
documents: ReadonlyMap<string, ProjectFile>,
|
||||
) {
|
||||
this.root = resolve(root)
|
||||
this.origin = origin
|
||||
this.profile = profile
|
||||
this.documents = documents
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory project blueprint without touching the target directory.
|
||||
* @param root - target project directory.
|
||||
* @param request - complete creation request.
|
||||
* @returns uncommitted project snapshot.
|
||||
*/
|
||||
static create(root: string, request: ProjectCreationRequest): SdkProject {
|
||||
const app = request.features.find(selection => selection.id === 'app')
|
||||
const selectedInterface = app?.options[0]
|
||||
if (selectedInterface !== 'acp' && selectedInterface !== 'embed') {
|
||||
throw new Error('project creation requires one app feature option')
|
||||
}
|
||||
const profile: ProjectProfile = {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
runtime: request.runtime,
|
||||
runInterface: selectedInterface,
|
||||
packageManager: request.packageManager,
|
||||
releaseVersion: request.releaseVersion,
|
||||
...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {},
|
||||
}
|
||||
const templates = createProjectTemplateContext(profile)
|
||||
const manifest = createPackageJsonDoc(templates)
|
||||
const documents = new Map<string, ProjectFile>()
|
||||
documents.set(manifest.relativePath, manifest)
|
||||
documents.set('cordis.yml', CordisYamlFile.create())
|
||||
documents.set('.env.example', EnvFile.create('.env.example'))
|
||||
documents.set('tsconfig.json', TsConfigFile.create())
|
||||
for (const document of request.packageManager.configureWorkspace(manifest)) {
|
||||
documents.set(document.relativePath, document)
|
||||
}
|
||||
for (const document of createBaselineProjectArtifacts(templates)) {
|
||||
documents.set(document.relativePath, document)
|
||||
}
|
||||
return new SdkProject(root, 'create', profile, documents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an existing project from required and SDK-managed optional files.
|
||||
* @param root - existing project directory.
|
||||
* @returns disk-backed project snapshot.
|
||||
* @throws When the config references the removed `@deepseek-ai/dsh-tui` root or a subpath.
|
||||
*/
|
||||
static async open(root: string): Promise<SdkProject> {
|
||||
const absolute = resolve(root)
|
||||
const [manifestText, cordisText] = await Promise.all([
|
||||
readFile(resolve(absolute, 'package.json'), 'utf8'),
|
||||
readFile(resolve(absolute, 'cordis.yml'), 'utf8'),
|
||||
])
|
||||
const manifest = PackageJsonFile.parse(manifestText)
|
||||
const cordis = CordisYamlFile.parse(cordisText)
|
||||
const value = manifest.value()
|
||||
const manager = await detectPackageManager(absolute, value)
|
||||
const entries = cordis.entries()
|
||||
const linkWorkspaceRoot = linkedRepositoryRoot(absolute, value)
|
||||
const profile: ProjectProfile = {
|
||||
name: value.name ?? basename(absolute),
|
||||
description: typeof value.description === 'string' ? value.description : '',
|
||||
runtime: { model: runtimeModel(entries) },
|
||||
runInterface: runInterface(entries),
|
||||
packageManager: manager,
|
||||
releaseVersion: releaseVersion(value),
|
||||
...linkWorkspaceRoot ? { linkWorkspaceRoot } : {},
|
||||
}
|
||||
const documents = new Map<string, ProjectFile>([
|
||||
['package.json', manifest],
|
||||
['cordis.yml', cordis],
|
||||
])
|
||||
await Promise.all(OPTIONAL_DOCUMENTS.map(async (path) => {
|
||||
try {
|
||||
const text = await readFile(resolve(absolute, path), 'utf8')
|
||||
documents.set(path, parseOptionalDocument(path, text))
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- optional-file reads fail normally only with ENOENT; other IO faults surface */
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
}))
|
||||
return new SdkProject(absolute, 'disk', profile, documents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the root package manifest defensively.
|
||||
* @returns cloned manifest value.
|
||||
*/
|
||||
packageManifest(): Readonly<PackageManifest> {
|
||||
return this.packageJson.value()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Cordis config entries defensively in file order.
|
||||
* @returns cloned Cordis config entries.
|
||||
*/
|
||||
cordisConfigEntries(): readonly CordisConfigEntry[] {
|
||||
return this.cordis.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this snapshot contains one managed document.
|
||||
* @param path - project-relative document path.
|
||||
* @returns whether the document is loaded.
|
||||
*/
|
||||
hasDocument(path: string): boolean {
|
||||
return this.documents.has(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one environment variable from a loaded dotenv document.
|
||||
* @param path - environment file to read.
|
||||
* @param name - variable name.
|
||||
* @returns variable value when present.
|
||||
*/
|
||||
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined {
|
||||
const document = this.documents.get(path)
|
||||
if (!document) return undefined
|
||||
if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`)
|
||||
return document.get(name)
|
||||
}
|
||||
|
||||
/** Read the root package document. */
|
||||
get packageJson(): PackageJsonFile {
|
||||
const document = this.documents.get('package.json')
|
||||
if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing or invalid')
|
||||
return document
|
||||
}
|
||||
|
||||
/** Read the root Cordis document. */
|
||||
get cordis(): CordisYamlFile {
|
||||
const document = this.documents.get('cordis.yml')
|
||||
if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing or invalid')
|
||||
return document
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one managed document without exposing the aggregate map.
|
||||
* @param path - project-relative document path.
|
||||
* @returns loaded document when present.
|
||||
*/
|
||||
document(path: string): ProjectFile | undefined {
|
||||
return this.documents.get(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the only mutable boundary for this snapshot.
|
||||
* @param registry - feature catalog governing edits.
|
||||
* @returns isolated edit session.
|
||||
*/
|
||||
edit(registry: FeatureRegistry): ProjectEditSession {
|
||||
return new ProjectEditSession(this, registry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone every managed document for an isolated edit session.
|
||||
* @returns project-relative document map.
|
||||
*/
|
||||
cloneDocuments(): Map<string, ProjectFile> {
|
||||
return new Map([...this.documents].map(([path, document]) => [path, document.clone()]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload this aggregate from committed disk state.
|
||||
* @returns fresh disk-backed snapshot.
|
||||
*/
|
||||
reopen(): Promise<SdkProject> {
|
||||
return SdkProject.open(this.root)
|
||||
}
|
||||
|
||||
}
|
||||
48
packages/scaffold/helper/src/project/types.ts
Normal file
48
packages/scaffold/helper/src/project/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared creation and project-profile values for SDK project editing.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/project/types
|
||||
*/
|
||||
|
||||
import type { PackageManager } from '../package-managers/package-manager.ts'
|
||||
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
|
||||
import type { FeatureId } from '../ids.ts'
|
||||
|
||||
/** Runtime front door selected for a generated project. */
|
||||
export type RunInterface = 'acp' | 'embed'
|
||||
|
||||
/** Values shared by the required provider and app features. */
|
||||
interface ProjectRuntimeOptions {
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Selected options and captured secrets for one feature. */
|
||||
export interface FeatureSelection {
|
||||
id: FeatureId
|
||||
options: readonly string[]
|
||||
values?: Readonly<Record<string, unknown>>
|
||||
secrets?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** Stable context available to project and feature objects. */
|
||||
export interface ProjectProfile {
|
||||
name: string
|
||||
description: string
|
||||
runtime: ProjectRuntimeOptions
|
||||
runInterface: RunInterface
|
||||
packageManager: PackageManager
|
||||
releaseVersion: string
|
||||
linkWorkspaceRoot?: string
|
||||
}
|
||||
|
||||
/** Fully collected create request; it contains intent, never rendered file text. */
|
||||
export interface ProjectCreationRequest {
|
||||
name: string
|
||||
description: string
|
||||
runtime: ProjectRuntimeOptions
|
||||
packageManager: PackageManager
|
||||
releaseVersion: string
|
||||
linkWorkspaceRoot?: string
|
||||
features: readonly FeatureSelection[]
|
||||
localPlugins: readonly LocalPluginBlueprint[]
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Tree-shaped Clack picker for root checkboxes with finite child options.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/questions/clack-nested-multiselect
|
||||
*/
|
||||
|
||||
import { styleText } from 'node:util'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { Prompt, isCancel } from '@clack/core'
|
||||
import {
|
||||
S_BAR,
|
||||
S_BAR_END,
|
||||
S_CHECKBOX_ACTIVE,
|
||||
S_CHECKBOX_INACTIVE,
|
||||
S_CHECKBOX_SELECTED,
|
||||
S_RADIO_ACTIVE,
|
||||
S_RADIO_INACTIVE,
|
||||
symbol,
|
||||
symbolBar,
|
||||
} from '@clack/prompts'
|
||||
import type {
|
||||
NestedMultiSelectOption,
|
||||
NestedMultiSelectRequest,
|
||||
NestedMultiSelectValue,
|
||||
PromptOutcome,
|
||||
} from './prompt-port.ts'
|
||||
|
||||
interface NestedPromptOptions<TValue, TChoice> extends NestedMultiSelectRequest<TValue, TChoice> {
|
||||
input: Readable
|
||||
output: Writable
|
||||
}
|
||||
|
||||
class NestedPrompt<TValue, TChoice> extends Prompt<readonly NestedMultiSelectValue<TValue, TChoice>[]> {
|
||||
readonly options: readonly NestedMultiSelectOption<TValue, TChoice>[]
|
||||
private readonly selected = new Set<TValue>()
|
||||
private readonly selectedChoices = new Map<TValue, Set<TChoice>>()
|
||||
private readonly initialSelected: Set<TValue>
|
||||
private readonly initialChoices: Map<TValue, Set<TChoice>>
|
||||
private readonly showChanges: boolean
|
||||
private layer: 'root' | 'choices' = 'root'
|
||||
private rootCursor = 0
|
||||
private choiceCursor = 0
|
||||
|
||||
constructor(options: NestedPromptOptions<TValue, TChoice>) {
|
||||
super({
|
||||
input: options.input,
|
||||
output: options.output,
|
||||
validate: value => NestedPrompt.validate(options.options, value),
|
||||
render(this: Prompt<readonly NestedMultiSelectValue<TValue, TChoice>[]>) {
|
||||
return (this as NestedPrompt<TValue, TChoice>).renderFrame(options.message)
|
||||
},
|
||||
}, false)
|
||||
this.options = options.options
|
||||
this.showChanges = options.showChanges ?? false
|
||||
for (const option of options.options) {
|
||||
if (option.required || option.default) this.selected.add(option.value)
|
||||
this.selectedChoices.set(option.value, new Set(
|
||||
option.choices?.filter(choice => choice.default).map(choice => choice.value) ?? [],
|
||||
))
|
||||
}
|
||||
this.initialSelected = new Set(this.selected)
|
||||
this.initialChoices = new Map([...this.selectedChoices].map(([value, choices]) => [
|
||||
value, new Set(choices),
|
||||
]))
|
||||
this.updateValue()
|
||||
this.on('cursor', (action) => { this.handleAction(action) })
|
||||
}
|
||||
|
||||
private static validate<TValue, TChoice>(
|
||||
options: readonly NestedMultiSelectOption<TValue, TChoice>[],
|
||||
value: readonly NestedMultiSelectValue<TValue, TChoice>[] | undefined,
|
||||
): string | undefined {
|
||||
/* v8 ignore next -- NestedPrompt initializes its value before submission validation */
|
||||
const selected = new Map(value?.map(item => [item.value, item.choices]) ?? [])
|
||||
for (const option of options) {
|
||||
if (option.disabled) continue
|
||||
/* v8 ignore next -- required options initialize selected and cannot be toggled off */
|
||||
if (option.required && !selected.has(option.value)) return `${option.label} is required`
|
||||
if (!selected.has(option.value) || !option.choiceMode) continue
|
||||
const choices = selected.get(option.value)
|
||||
/* v8 ignore next -- selected.has above guarantees the map value exists */
|
||||
if (!choices) continue
|
||||
const count = choices.length
|
||||
if (option.choiceMode === 'exclusive' && count !== 1) return `Choose one ${option.label} option`
|
||||
if (option.choiceMode === 'multiple' && count === 0) return `Choose at least one ${option.label} option`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
protected override _shouldSubmit(): boolean {
|
||||
if (this.layer === 'choices') {
|
||||
this.leaveChoices()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private handleAction(action: string | undefined): void {
|
||||
if (this.layer === 'root') this.handleRootAction(action)
|
||||
else this.handleChoiceAction(action)
|
||||
this.updateValue()
|
||||
}
|
||||
|
||||
private handleRootAction(action: string | undefined): void {
|
||||
if (action === 'up') this.rootCursor = this.move(this.rootCursor, -1, this.options.length)
|
||||
if (action === 'down') this.rootCursor = this.move(this.rootCursor, 1, this.options.length)
|
||||
const option = this.options[this.rootCursor]
|
||||
/* v8 ignore next -- Clack cannot emit a cursor action when the option list is empty */
|
||||
if (!option) return
|
||||
if (action === 'space' && !option.required && !option.disabled) {
|
||||
if (this.selected.has(option.value)) this.selected.delete(option.value)
|
||||
else this.selected.add(option.value)
|
||||
}
|
||||
if (action === 'right' && !option.disabled && option.choices && option.choices.length > 0) {
|
||||
this.selected.add(option.value)
|
||||
this.layer = 'choices'
|
||||
const selected = this.selectedChoices.get(option.value)
|
||||
const selectedIndex = option.choices.findIndex(choice => selected?.has(choice.value))
|
||||
this.choiceCursor = Math.max(selectedIndex, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private handleChoiceAction(action: string | undefined): void {
|
||||
const rootOption = this.options[this.rootCursor]
|
||||
/* v8 ignore next -- the choices layer is entered only from a concrete root option */
|
||||
if (!rootOption) return
|
||||
/* v8 ignore next -- the choices layer is entered only for a non-empty choices array */
|
||||
const choices = rootOption.choices ?? []
|
||||
if (action === 'left') {
|
||||
this.leaveChoices()
|
||||
return
|
||||
}
|
||||
if (action === 'up') this.choiceCursor = this.move(this.choiceCursor, -1, choices.length)
|
||||
if (action === 'down') this.choiceCursor = this.move(this.choiceCursor, 1, choices.length)
|
||||
if ((action === 'up' || action === 'down') && rootOption.choiceMode === 'exclusive') {
|
||||
const choice = choices[this.choiceCursor]
|
||||
/* v8 ignore else -- a cursor in the non-empty choices layer always addresses a choice */
|
||||
if (choice) this.selectedChoices.set(rootOption.value, new Set([choice.value]))
|
||||
}
|
||||
if (action !== 'space' && action !== 'right') return
|
||||
const choice = choices[this.choiceCursor]
|
||||
/* v8 ignore next -- the choices layer requires a non-empty choice list */
|
||||
if (!choice) return
|
||||
/* v8 ignore next -- every root option initializes its choice set in the constructor */
|
||||
const selected = this.selectedChoices.get(rootOption.value) ?? new Set<TChoice>()
|
||||
if (rootOption.choiceMode === 'exclusive') {
|
||||
selected.clear()
|
||||
selected.add(choice.value)
|
||||
} else if (selected.has(choice.value)) selected.delete(choice.value)
|
||||
else selected.add(choice.value)
|
||||
this.selectedChoices.set(rootOption.value, selected)
|
||||
}
|
||||
|
||||
private move(cursor: number, offset: number, length: number): number {
|
||||
/* v8 ignore next -- cursor movement is emitted only for a non-empty displayed list */
|
||||
if (length === 0) return 0
|
||||
return (cursor + offset + length) % length
|
||||
}
|
||||
|
||||
private updateValue(): void {
|
||||
this._setValue(this.options.filter(option => this.selected.has(option.value)).map(option => ({
|
||||
value: option.value,
|
||||
/* v8 ignore next -- every root option initializes its choice set in the constructor */
|
||||
choices: [...this.selectedChoices.get(option.value) ?? []],
|
||||
})))
|
||||
}
|
||||
|
||||
private renderFrame(message: string): string {
|
||||
const header = `${symbolBar(this.state)} ${message}`
|
||||
if (this.state === 'submit') {
|
||||
/* v8 ignore next -- NestedPrompt initializes its value before it can submit */
|
||||
const summary = (this.value ?? []).map(item => this.options.find(option => option.value === item.value)?.label)
|
||||
.filter(Boolean).join(', ') || 'none'
|
||||
return `${symbol(this.state)} ${message}\n${styleText('gray', S_BAR)} ${styleText('dim', summary)}`
|
||||
}
|
||||
if (this.state === 'cancel') return `${symbol(this.state)} ${message}`
|
||||
const body = this.layer === 'root' ? this.renderRoot() : this.renderChoices()
|
||||
const instructions = this.layer === 'root'
|
||||
? `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space')} select ${styleText('dim', '→')} configure ${styleText('dim', 'Enter')} confirm`
|
||||
: `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space/→')} select ${styleText('dim', '←/Enter')} back`
|
||||
const error = this.state === 'error' ? `\n${styleText('yellow', `${S_BAR_END} ${this.error}`)}` : ''
|
||||
return `${header}\n${styleText('cyan', S_BAR)} ${body.join(`\n${styleText('cyan', S_BAR)} `)}\n${styleText('cyan', S_BAR_END)} ${instructions}${error}`
|
||||
}
|
||||
|
||||
private renderRoot(): string[] {
|
||||
return this.options.map((option, index) => {
|
||||
const active = index === this.rootCursor
|
||||
const selected = this.selected.has(option.value)
|
||||
const focus = active ? styleText('cyan', '›') : ' '
|
||||
const checkbox = selected
|
||||
? styleText('green', S_CHECKBOX_SELECTED)
|
||||
: styleText('dim', active ? S_CHECKBOX_ACTIVE : S_CHECKBOX_INACTIVE)
|
||||
const choices = option.choices?.filter(choice => this.selectedChoices.get(option.value)?.has(choice.value))
|
||||
.map(choice => choice.label).join(', ')
|
||||
const suffix = option.choices?.length
|
||||
? ` ${styleText('dim', `* →${choices ? ` ${choices}` : ''}`)}`
|
||||
: ''
|
||||
const required = option.required ? ` ${styleText('yellow', '(required)')}` : ''
|
||||
const issue = this.choiceIssue(option)
|
||||
const warningText = option.warning ?? issue
|
||||
const warning = warningText ? ` ${styleText('yellow', `▲ ${warningText}`)}` : ''
|
||||
const changed = this.optionChanged(option)
|
||||
const change = changed ? ` ${styleText('yellow', '● changed')}` : ''
|
||||
const label = active
|
||||
? styleText('cyan', option.label)
|
||||
: changed
|
||||
? styleText('yellow', option.label)
|
||||
: selected ? styleText('green', option.label) : styleText('dim', option.label)
|
||||
const line = `${focus} ${checkbox} ${label}${required}${suffix}${warning}${change}`
|
||||
return option.disabled ? styleText('gray', line) : line
|
||||
})
|
||||
}
|
||||
|
||||
private renderChoices(): string[] {
|
||||
const rootOption = this.options[this.rootCursor]
|
||||
/* v8 ignore next -- renderChoices runs only after entering from a concrete root option */
|
||||
if (!rootOption) return []
|
||||
/* v8 ignore next -- every root option initializes its choice set in the constructor */
|
||||
const selected = this.selectedChoices.get(rootOption.value) ?? new Set<TChoice>()
|
||||
const issue = this.choiceIssue(rootOption)
|
||||
const changed = this.optionChanged(rootOption)
|
||||
const header = styleText('dim', `${rootOption.label} options`)
|
||||
+ (issue ? ` ${styleText('yellow', `▲ ${issue}`)}` : '')
|
||||
+ (changed ? ` ${styleText('yellow', '● changed')}` : '')
|
||||
const choices = rootOption.choices
|
||||
/* v8 ignore next -- the choices layer is entered only for a non-empty choices array */
|
||||
if (!choices) return [header]
|
||||
return [
|
||||
header,
|
||||
...choices.map((choice, index) => {
|
||||
const active = index === this.choiceCursor
|
||||
const checked = selected.has(choice.value)
|
||||
const choiceChanged = this.choiceChanged(rootOption.value, choice.value)
|
||||
const focus = active ? styleText('cyan', '›') : ' '
|
||||
const marker = rootOption.choiceMode === 'exclusive'
|
||||
? checked ? styleText('green', S_RADIO_ACTIVE) : styleText('dim', S_RADIO_INACTIVE)
|
||||
: checked ? styleText('green', S_CHECKBOX_SELECTED) : styleText('dim', S_CHECKBOX_INACTIVE)
|
||||
const label = active
|
||||
? styleText('cyan', choice.label)
|
||||
: choiceChanged
|
||||
? styleText('yellow', choice.label)
|
||||
: checked ? styleText('green', choice.label) : styleText('dim', choice.label)
|
||||
const change = choiceChanged ? ` ${styleText('yellow', '●')}` : ''
|
||||
return `${focus} ${marker} ${label}${change}`
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
private optionChanged(option: NestedMultiSelectOption<TValue, TChoice>): boolean {
|
||||
if (!this.showChanges) return false
|
||||
const selected = this.selected.has(option.value)
|
||||
const initiallySelected = this.initialSelected.has(option.value)
|
||||
if (selected !== initiallySelected) return true
|
||||
if (!selected) return false
|
||||
/* v8 ignore next -- every root option initializes both current and baseline option sets */
|
||||
const current = this.selectedChoices.get(option.value) ?? new Set<TChoice>()
|
||||
/* v8 ignore next -- every root option initializes both current and baseline option sets */
|
||||
const initial = this.initialChoices.get(option.value) ?? new Set<TChoice>()
|
||||
return current.size !== initial.size || [...current].some(value => !initial.has(value))
|
||||
}
|
||||
|
||||
private choiceChanged(value: TValue, choice: TChoice): boolean {
|
||||
if (!this.showChanges) return false
|
||||
return this.selectedChoices.get(value)?.has(choice) !== this.initialChoices.get(value)?.has(choice)
|
||||
}
|
||||
|
||||
private choiceIssue(option: NestedMultiSelectOption<TValue, TChoice>): string | undefined {
|
||||
if (option.disabled || !this.selected.has(option.value) || !option.choiceMode) return undefined
|
||||
/* v8 ignore next -- every root option initializes its choice set in the constructor */
|
||||
const count = this.selectedChoices.get(option.value)?.size ?? 0
|
||||
if (option.choiceMode === 'exclusive' && count !== 1) return 'choose one'
|
||||
if (option.choiceMode === 'multiple' && count === 0) return 'choose at least one'
|
||||
return undefined
|
||||
}
|
||||
|
||||
private leaveChoices(): boolean {
|
||||
const option = this.options[this.rootCursor]
|
||||
/* v8 ignore next -- leaveChoices runs only after entering from a concrete root option */
|
||||
if (!option) return false
|
||||
const issue = this.choiceIssue(option)
|
||||
if (issue) {
|
||||
this.error = `${option.label}: ${issue}`
|
||||
this.state = 'error'
|
||||
return false
|
||||
}
|
||||
this.error = ''
|
||||
this.layer = 'root'
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the nested picker with Clack's standard cancellation symbol. */
|
||||
export async function clackNestedMultiselect<TValue, TChoice>(
|
||||
request: NestedPromptOptions<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
const value = await new NestedPrompt(request).prompt()
|
||||
return isCancel(value)
|
||||
? { status: 'cancelled' }
|
||||
: {
|
||||
status: 'answered',
|
||||
/* v8 ignore next -- NestedPrompt initializes its value before it can submit */
|
||||
value: value ?? [],
|
||||
}
|
||||
}
|
||||
127
packages/scaffold/helper/src/questions/clack-prompt-port.ts
Normal file
127
packages/scaffold/helper/src/questions/clack-prompt-port.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Thin @clack/prompts adapter for the shared prompt port.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/questions/clack-prompt-port
|
||||
*/
|
||||
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { styleText } from 'node:util'
|
||||
import {
|
||||
confirm,
|
||||
isCancel,
|
||||
multiselect,
|
||||
password,
|
||||
select,
|
||||
text,
|
||||
S_WARN,
|
||||
} from '@clack/prompts'
|
||||
import type { Option } from '@clack/prompts'
|
||||
import type {
|
||||
ConfirmPromptRequest,
|
||||
MultiSelectPromptRequest,
|
||||
NestedMultiSelectRequest,
|
||||
NestedMultiSelectValue,
|
||||
PromptOutcome,
|
||||
PromptPort,
|
||||
SecretPromptRequest,
|
||||
SelectPromptRequest,
|
||||
TextPromptRequest,
|
||||
} from './prompt-port.ts'
|
||||
import { clackNestedMultiselect } from './clack-nested-multiselect.ts'
|
||||
|
||||
function outcome<T>(value: T | symbol): PromptOutcome<T> {
|
||||
return isCancel(value) ? { status: 'cancelled' } : { status: 'answered', value }
|
||||
}
|
||||
|
||||
function clackOptions<T>(values: readonly import('./prompt-port.ts').PromptOption<T>[]): Option<T>[] {
|
||||
return values.map(value => ({
|
||||
value: value.value,
|
||||
label: value.label,
|
||||
...value.hint === undefined ? {} : { hint: value.hint },
|
||||
...value.disabled === undefined ? {} : { disabled: value.disabled },
|
||||
})) as Option<T>[]
|
||||
}
|
||||
|
||||
/** Clack-backed prompt adapter with injectable streams for snapshots and tests. */
|
||||
export class ClackPromptPort implements PromptPort {
|
||||
private readonly input: Readable
|
||||
private readonly output: Writable
|
||||
|
||||
/** Bind all prompts to one input/output pair. */
|
||||
constructor(input: Readable = process.stdin, output: Writable = process.stdout) {
|
||||
this.input = input
|
||||
this.output = output
|
||||
}
|
||||
|
||||
/** Ask for visible text through clack. */
|
||||
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return outcome(await text({
|
||||
message: request.message,
|
||||
...request.placeholder === undefined ? {} : { placeholder: request.placeholder },
|
||||
...request.initialValue === undefined ? {} : { initialValue: request.initialValue },
|
||||
...request.defaultValue === undefined ? {} : { defaultValue: request.defaultValue },
|
||||
...request.validate === undefined
|
||||
? {}
|
||||
: {
|
||||
/* v8 ignore next -- value/default precedence is exercised through the adapter contract tests */
|
||||
validate: value => request.validate?.(value || request.defaultValue || ''),
|
||||
},
|
||||
input: this.input,
|
||||
output: this.output,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Ask for a masked secret through clack. */
|
||||
async secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return outcome(await password({
|
||||
message: request.message,
|
||||
...request.validate === undefined ? {} : {
|
||||
/* v8 ignore next -- @clack/password always calls validation with a string; fallback is defensive */
|
||||
validate: value => request.validate?.(value ?? ''),
|
||||
},
|
||||
input: this.input,
|
||||
output: this.output,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Ask for one option through clack. */
|
||||
async select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
|
||||
return outcome(await select({
|
||||
...request,
|
||||
options: clackOptions(request.options),
|
||||
input: this.input,
|
||||
output: this.output,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Ask for multiple options through clack. */
|
||||
async multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
return outcome(await multiselect({
|
||||
message: request.message,
|
||||
options: clackOptions(request.options),
|
||||
...request.initialValues === undefined ? {} : { initialValues: [...request.initialValues] },
|
||||
...request.required === undefined ? {} : { required: request.required },
|
||||
input: this.input,
|
||||
output: this.output,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Ask for confirmation through clack. */
|
||||
async confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
|
||||
return outcome(await confirm({
|
||||
message: request.tone === 'warning'
|
||||
? styleText('yellow', `${S_WARN} ${request.message}`)
|
||||
: request.message,
|
||||
...request.initialValue === undefined ? {} : { initialValue: request.initialValue },
|
||||
input: this.input,
|
||||
output: this.output,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Select root values and finite child options in one tree prompt. */
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return clackNestedMultiselect({ ...request, input: this.input, output: this.output })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Non-interactive prompt port for headless create/config and skill-driven runs.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
|
||||
*/
|
||||
|
||||
import type {
|
||||
ConfirmPromptRequest,
|
||||
MultiSelectPromptRequest,
|
||||
NestedMultiSelectRequest,
|
||||
NestedMultiSelectValue,
|
||||
PromptOutcome,
|
||||
PromptPort,
|
||||
SecretPromptRequest,
|
||||
SelectPromptRequest,
|
||||
TextPromptRequest,
|
||||
} from './prompt-port.ts'
|
||||
|
||||
/**
|
||||
* Raised when a headless run reaches a decision that was neither prefilled nor
|
||||
* carries a usable default. The message names the unanswered prompt so an agent
|
||||
* or CI caller can see exactly which input the spec must supply.
|
||||
*/
|
||||
export class HeadlessPromptError extends Error {
|
||||
/** The unanswered prompt's user-facing message. */
|
||||
readonly prompt: string
|
||||
|
||||
/** Build an error naming the unanswered prompt. */
|
||||
constructor(prompt: string) {
|
||||
super(`headless run needs an answer for: ${prompt}`)
|
||||
this.name = 'HeadlessPromptError'
|
||||
this.prompt = prompt
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an answered outcome. */
|
||||
function answered<T>(value: T): Promise<PromptOutcome<T>> {
|
||||
return Promise.resolve({ status: 'answered', value })
|
||||
}
|
||||
|
||||
/** Reject with a named unanswered-prompt error. */
|
||||
function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
|
||||
return Promise.reject(new HeadlessPromptError(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link PromptPort} that never blocks on a terminal.
|
||||
*
|
||||
* Answers are expected to arrive as prefilled values through the `Question` /
|
||||
* `FeatureConfigurator` layers, so in a fully specified run this port is never
|
||||
* reached. When it *is* reached, it takes the prompt's own declared default
|
||||
* (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
|
||||
* {@link HeadlessPromptError}. Nested feature selection has no scalar default,
|
||||
* so it always fails loud — headless callers must supply the feature set through
|
||||
* the spec rather than the tree picker.
|
||||
*/
|
||||
export class HeadlessPromptPort implements PromptPort {
|
||||
/** Answer visible text from its default, or fail loud. */
|
||||
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
const fallback = request.initialValue ?? request.defaultValue
|
||||
if (fallback === undefined) return unanswered(request.message)
|
||||
const diagnostic = request.validate?.(fallback)
|
||||
if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
|
||||
return answered(fallback)
|
||||
}
|
||||
|
||||
/** A secret has no safe default: always fail loud. */
|
||||
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return unanswered(request.message)
|
||||
}
|
||||
|
||||
/** Answer a single choice from its initial value, or fail loud. */
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
|
||||
if (request.initialValue === undefined) return unanswered(request.message)
|
||||
return answered(request.initialValue)
|
||||
}
|
||||
|
||||
/** Answer a multi-choice from its initial values, or fail loud when required. */
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
const initial = request.initialValues ?? []
|
||||
if (request.required && initial.length === 0) return unanswered(request.message)
|
||||
return answered(initial)
|
||||
}
|
||||
|
||||
/** Answer a confirmation from its initial value, or fail loud. */
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
|
||||
if (request.initialValue === undefined) return unanswered(request.message)
|
||||
return answered(request.initialValue)
|
||||
}
|
||||
|
||||
/** Nested feature selection has no scalar default: always fail loud. */
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return unanswered(request.message)
|
||||
}
|
||||
}
|
||||
124
packages/scaffold/helper/src/questions/prompt-port.ts
Normal file
124
packages/scaffold/helper/src/questions/prompt-port.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Terminal-prompt port shared by create and config workflows.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/questions/prompt-port
|
||||
*/
|
||||
|
||||
/** One selectable prompt option. */
|
||||
export interface PromptOption<T> {
|
||||
value: T
|
||||
label: string
|
||||
hint?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Answer or explicit cancellation returned by every prompt. */
|
||||
export type PromptOutcome<T> =
|
||||
| { status: 'answered'; value: T }
|
||||
| { status: 'cancelled' }
|
||||
|
||||
/** Input for one text prompt. */
|
||||
export interface TextPromptRequest {
|
||||
message: string
|
||||
placeholder?: string
|
||||
initialValue?: string
|
||||
defaultValue?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
}
|
||||
|
||||
/** Input for one masked secret prompt. */
|
||||
export interface SecretPromptRequest {
|
||||
message: string
|
||||
validate?: (value: string) => string | undefined
|
||||
}
|
||||
|
||||
/** Input for one single-choice prompt. */
|
||||
export interface SelectPromptRequest<T> {
|
||||
message: string
|
||||
options: readonly PromptOption<T>[]
|
||||
initialValue?: T
|
||||
}
|
||||
|
||||
/** Input for one additive multi-choice prompt. */
|
||||
export interface MultiSelectPromptRequest<T> {
|
||||
message: string
|
||||
options: readonly PromptOption<T>[]
|
||||
initialValues?: readonly T[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
/** Input for one yes/no prompt. */
|
||||
export interface ConfirmPromptRequest {
|
||||
message: string
|
||||
initialValue?: boolean
|
||||
tone?: 'default' | 'warning'
|
||||
}
|
||||
|
||||
/** One nested choice under a multi-select option. */
|
||||
interface NestedSelectChoice<T> {
|
||||
value: T
|
||||
label: string
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
/** One root option with optional child option configuration. */
|
||||
export interface NestedMultiSelectOption<TValue, TChoice> {
|
||||
value: TValue
|
||||
label: string
|
||||
required?: boolean
|
||||
default?: boolean
|
||||
disabled?: boolean
|
||||
warning?: string
|
||||
choiceMode?: 'exclusive' | 'multiple'
|
||||
choices?: readonly NestedSelectChoice<TChoice>[]
|
||||
}
|
||||
|
||||
/** Input for a tree-shaped feature-style picker. */
|
||||
export interface NestedMultiSelectRequest<TValue, TChoice> {
|
||||
message: string
|
||||
options: readonly NestedMultiSelectOption<TValue, TChoice>[]
|
||||
showChanges?: boolean
|
||||
}
|
||||
|
||||
/** One selected root option and its child options. */
|
||||
export interface NestedMultiSelectValue<TValue, TChoice> {
|
||||
value: TValue
|
||||
choices: readonly TChoice[]
|
||||
}
|
||||
|
||||
/** Interaction boundary consumed by typed question objects. */
|
||||
export interface PromptPort {
|
||||
/** Ask for one line of visible text. */
|
||||
text(request: TextPromptRequest): Promise<PromptOutcome<string>>
|
||||
/** Ask for one masked value. */
|
||||
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>>
|
||||
/** Ask for exactly one option. */
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>>
|
||||
/** Ask for zero or more options. */
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>>
|
||||
/** Ask for a boolean confirmation. */
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>>
|
||||
/** Select root options and configure finite child options in one tree prompt. */
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>>
|
||||
}
|
||||
|
||||
/** Error used when a workflow chooses to turn prompt cancellation into command cancellation. */
|
||||
export class PromptCancelledError extends Error {
|
||||
/** Create a stable cancellation error. */
|
||||
constructor(message = 'operation cancelled') {
|
||||
super(message)
|
||||
this.name = 'PromptCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an answered value or throw the shared cancellation error.
|
||||
* @param outcome - prompt result to unwrap.
|
||||
* @returns answered value.
|
||||
*/
|
||||
export function requireAnswer<T>(outcome: PromptOutcome<T>): T {
|
||||
if (outcome.status === 'cancelled') throw new PromptCancelledError()
|
||||
return outcome.value
|
||||
}
|
||||
206
packages/scaffold/helper/src/questions/question.ts
Normal file
206
packages/scaffold/helper/src/questions/question.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Typed question objects with prefill, validation, and prompt behavior together.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/questions/question
|
||||
*/
|
||||
|
||||
import type { PromptOption, PromptOutcome, PromptPort } from './prompt-port.ts'
|
||||
|
||||
function resolvePrefilled(
|
||||
id: string,
|
||||
value: string | undefined,
|
||||
validate: ((value: string) => string | undefined) | undefined,
|
||||
): PromptOutcome<string> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const diagnostic = validate?.(value)
|
||||
if (diagnostic) throw new Error(`${id}: ${diagnostic}`)
|
||||
return { status: 'answered', value }
|
||||
}
|
||||
|
||||
/** A typed business question resolved from prefilled input or one prompt call. */
|
||||
export abstract class Question<T> {
|
||||
/** Stable question identity used in diagnostics. */
|
||||
readonly id: string
|
||||
/** User-facing prompt text. */
|
||||
readonly message: string
|
||||
|
||||
protected constructor(id: string, message: string) {
|
||||
this.id = id
|
||||
this.message = message
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a prefilled answer without prompting, or ask through the port.
|
||||
* @param port - prompt interaction boundary.
|
||||
* @param prefilled - optional value supplied by CLI or current project state.
|
||||
* @returns answered or cancelled prompt outcome.
|
||||
*/
|
||||
abstract resolve(port: PromptPort, prefilled?: T): Promise<PromptOutcome<T>>
|
||||
}
|
||||
|
||||
/** Visible single-line text question. */
|
||||
export class TextQuestion extends Question<string> {
|
||||
/** Light hint displayed when no text has been entered. */
|
||||
readonly placeholder: string | undefined
|
||||
/** Editable value displayed in the input. */
|
||||
readonly initialValue: string | undefined
|
||||
/** Value accepted when the user submits an empty input. */
|
||||
readonly defaultValue: string | undefined
|
||||
private readonly validate: ((value: string) => string | undefined) | undefined
|
||||
|
||||
/** Configure one text question. */
|
||||
constructor(options: {
|
||||
id: string
|
||||
message: string
|
||||
placeholder?: string
|
||||
initialValue?: string
|
||||
defaultValue?: string
|
||||
validate?: (value: string) => string | undefined
|
||||
}) {
|
||||
super(options.id, options.message)
|
||||
this.placeholder = options.placeholder
|
||||
this.initialValue = options.initialValue
|
||||
this.defaultValue = options.defaultValue
|
||||
this.validate = options.validate
|
||||
}
|
||||
|
||||
/** Validate prefilled text or ask for it. */
|
||||
override async resolve(port: PromptPort, prefilled?: string): Promise<PromptOutcome<string>> {
|
||||
const resolved = resolvePrefilled(this.id, prefilled, this.validate)
|
||||
if (resolved) return resolved
|
||||
return port.text({
|
||||
message: this.message,
|
||||
...this.placeholder === undefined ? {} : { placeholder: this.placeholder },
|
||||
...this.initialValue === undefined ? {} : { initialValue: this.initialValue },
|
||||
...this.defaultValue === undefined ? {} : { defaultValue: this.defaultValue },
|
||||
...this.validate === undefined ? {} : { validate: this.validate },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Masked secret question whose empty-input semantics are set by its caller. */
|
||||
export class SecretQuestion extends Question<string> {
|
||||
private readonly validate: ((value: string) => string | undefined) | undefined
|
||||
|
||||
/** Configure one secret question. */
|
||||
constructor(options: {
|
||||
id: string
|
||||
message: string
|
||||
validate?: (value: string) => string | undefined
|
||||
}) {
|
||||
super(options.id, options.message)
|
||||
this.validate = options.validate
|
||||
}
|
||||
|
||||
/** Validate a prefilled secret or ask for a masked value. */
|
||||
override async resolve(port: PromptPort, prefilled?: string): Promise<PromptOutcome<string>> {
|
||||
const resolved = resolvePrefilled(this.id, prefilled, this.validate)
|
||||
if (resolved) return resolved
|
||||
return port.secret({
|
||||
message: this.message,
|
||||
...this.validate === undefined ? {} : { validate: this.validate },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-choice question. */
|
||||
export class SelectQuestion<T> extends Question<T> {
|
||||
/** Available choices in display order. */
|
||||
readonly options: readonly PromptOption<T>[]
|
||||
/** Initially focused choice. */
|
||||
readonly initialValue: T | undefined
|
||||
|
||||
/** Configure one single-choice question. */
|
||||
constructor(options: {
|
||||
id: string
|
||||
message: string
|
||||
options: readonly PromptOption<T>[]
|
||||
initialValue?: T
|
||||
}) {
|
||||
super(options.id, options.message)
|
||||
this.options = options.options
|
||||
this.initialValue = options.initialValue
|
||||
}
|
||||
|
||||
/** Validate a prefilled option or ask for one choice. */
|
||||
override async resolve(port: PromptPort, prefilled?: T): Promise<PromptOutcome<T>> {
|
||||
if (prefilled !== undefined) {
|
||||
if (!this.options.some(option => Object.is(option.value, prefilled) && !option.disabled)) {
|
||||
throw new Error(`${this.id}: unknown or disabled option ${String(prefilled)}`)
|
||||
}
|
||||
return { status: 'answered', value: prefilled }
|
||||
}
|
||||
return port.select({
|
||||
message: this.message,
|
||||
options: this.options,
|
||||
...this.initialValue === undefined ? {} : { initialValue: this.initialValue },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Additive multi-choice question. */
|
||||
export class MultiSelectQuestion<T> extends Question<readonly T[]> {
|
||||
readonly options: readonly PromptOption<T>[]
|
||||
readonly initialValues: readonly T[]
|
||||
readonly required: boolean
|
||||
|
||||
/** Configure one multi-choice question. */
|
||||
constructor(options: {
|
||||
id: string
|
||||
message: string
|
||||
options: readonly PromptOption<T>[]
|
||||
initialValues?: readonly T[]
|
||||
required?: boolean
|
||||
}) {
|
||||
super(options.id, options.message)
|
||||
this.options = options.options
|
||||
this.initialValues = options.initialValues ?? []
|
||||
this.required = options.required ?? false
|
||||
}
|
||||
|
||||
/** Validate prefilled values or ask for an additive selection. */
|
||||
override async resolve(port: PromptPort, prefilled?: readonly T[]): Promise<PromptOutcome<readonly T[]>> {
|
||||
if (prefilled !== undefined) {
|
||||
for (const value of prefilled) {
|
||||
/* v8 ignore next -- unknown, disabled, and accepted values are each pinned by the question tests */
|
||||
if (!this.options.some(option => Object.is(option.value, value) && option.disabled !== true)) {
|
||||
throw new Error(`${this.id}: unknown or disabled option ${String(value)}`)
|
||||
}
|
||||
}
|
||||
if (this.required && prefilled.length === 0) throw new Error(`${this.id}: choose at least one option`)
|
||||
return { status: 'answered', value: prefilled }
|
||||
}
|
||||
return port.multiselect({
|
||||
message: this.message,
|
||||
options: this.options,
|
||||
initialValues: this.initialValues,
|
||||
required: this.required,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Boolean confirmation question. */
|
||||
export class ConfirmQuestion extends Question<boolean> {
|
||||
/** Answer selected by pressing Enter. */
|
||||
readonly initialValue: boolean
|
||||
/** Visual severity used by the prompt adapter. */
|
||||
readonly tone: 'default' | 'warning'
|
||||
|
||||
/** Configure one confirmation question. */
|
||||
constructor(options: {
|
||||
id: string
|
||||
message: string
|
||||
initialValue?: boolean
|
||||
tone?: 'default' | 'warning'
|
||||
}) {
|
||||
super(options.id, options.message)
|
||||
this.initialValue = options.initialValue ?? true
|
||||
this.tone = options.tone ?? 'default'
|
||||
}
|
||||
|
||||
/** Return a prefilled boolean or ask for confirmation. */
|
||||
override async resolve(port: PromptPort, prefilled?: boolean): Promise<PromptOutcome<boolean>> {
|
||||
if (prefilled !== undefined) return { status: 'answered', value: prefilled }
|
||||
return port.confirm({ message: this.message, initialValue: this.initialValue, tone: this.tone })
|
||||
}
|
||||
}
|
||||
27
packages/scaffold/helper/src/templates/assets/README.md.tpl
Normal file
27
packages/scaffold/helper/src/templates/assets/README.md.tpl
Normal file
@@ -0,0 +1,27 @@
|
||||
# {{name}}
|
||||
|
||||
{{description}}
|
||||
|
||||
Built with the DeepSeek Harness SDK using the {{model}} model.
|
||||
|
||||
{{#if isAcp}}
|
||||
## Run as an ACP automation server
|
||||
|
||||
Run `{{packageManager}} start` and configure a programmatic ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
|
||||
{{else}}
|
||||
## Embed the harness
|
||||
|
||||
Import and call the exported `main()` from `index.ts` in your host application.
|
||||
{{/if}}
|
||||
|
||||
## Development
|
||||
|
||||
Install NPM dependencies with `{{packageManager}} {{installArgs}}`, then use:
|
||||
|
||||
- `dev`: `{{packageManager}} run dev`
|
||||
- `build`: `{{packageManager}} {{buildArgs}}`
|
||||
- `typecheck`: `{{packageManager}} run typecheck`
|
||||
- `start`: `{{packageManager}} start`
|
||||
- `config`: `{{packageManager}} run config`
|
||||
|
||||
Edit `cordis.yml` to change the runtime plugin tree. Add or remove builtin features with `{{packageManager}} exec dsh-sdk config`.
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
lib/
|
||||
.env
|
||||
.sessions/
|
||||
*.tsbuildinfo
|
||||
20
packages/scaffold/helper/src/templates/assets/index.ts.tpl
Normal file
20
packages/scaffold/helper/src/templates/assets/index.ts.tpl
Normal file
@@ -0,0 +1,20 @@
|
||||
{{#if isAcp}}
|
||||
import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
|
||||
{{else}}
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
|
||||
{{/if}}
|
||||
|
||||
/** Boot this project's cordis.yml when invoked by dsh-scripts. */
|
||||
export async function main(boot: SdkBootContext) {
|
||||
const ctx = await startSDK(new URL('./cordis.yml', import.meta.url))
|
||||
{{#if isEmbed}}
|
||||
await ctx.agents.create({
|
||||
sessionId: SessionId(`main-session-${randomUUID()}`),
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model: {{modelLiteral}} },
|
||||
})
|
||||
{{/if}}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { PluginBuild } from '@deepseek-ai/dsh-scripts/dev/tsdown-config'
|
||||
|
||||
export default defineConfig(PluginBuild({
|
||||
entry: ['src/index.ts'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: true,
|
||||
clean: false,
|
||||
}))
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Local Cordis plugin. */
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = '{{pluginName}}'
|
||||
|
||||
/** Register this plugin's project-local behavior. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => () => {})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Project-local model-facing tool. */
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = '{{pluginName}}'
|
||||
export const inject = ['tools']
|
||||
|
||||
/** Register the {{toolName}} tool. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: '{{toolName}}',
|
||||
description: 'Project-local {{toolTitle}} tool.',
|
||||
parameters: {},
|
||||
execute: async () => [{ type: 'text', text: '{{toolName}} completed.' }],
|
||||
presentCall: args => ({ card: 'generic', title: '{{toolTitle}}', kind: 'other', rawInput: args }),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": {{name}},
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": {{description}},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "dsh-sdk build",
|
||||
"typecheck": "tsc -b",
|
||||
"config": "dsh-sdk config"
|
||||
},
|
||||
"dependencies": {{dependencies}},
|
||||
"devDependencies": {{devDependencies}}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
You are a coding assistant powered by the \{{model}} model. Your working directory is \{{cwd}}.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"outDir": "lib",
|
||||
"rootDir": ".",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
import { ProjectBuild } from '@deepseek-ai/dsh-scripts/dev/tsdown-config'
|
||||
|
||||
export default defineConfig(ProjectBuild({
|
||||
entry: ['index.ts'],
|
||||
outDir: '.',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
}))
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
110
packages/scaffold/helper/src/templates/project-template.ts
Normal file
110
packages/scaffold/helper/src/templates/project-template.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Strict Handlebars wrapper and complete-file SDK project artifacts.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/templates/project-template
|
||||
*/
|
||||
|
||||
import { PackageJsonFile } from '../documents/package-json-file.ts'
|
||||
import { TextProjectFile } from '../documents/project-file.ts'
|
||||
import type { PackageManagerName } from '../package-managers/package-manager.ts'
|
||||
import { baselineNpmDependencies } from '../project/npm-dependency-policy.ts'
|
||||
import type { ProjectProfile, RunInterface } from '../project/types.ts'
|
||||
import { loadHelperTemplate } from './template-assets.ts'
|
||||
import type { TextTemplate } from './text-template.ts'
|
||||
|
||||
/** Stable typed view consumed by all generated text artifacts. */
|
||||
export interface ProjectTemplateContext {
|
||||
name: string
|
||||
description: string
|
||||
releaseVersion: string
|
||||
model: string
|
||||
modelLiteral: string
|
||||
isAcp: boolean
|
||||
isEmbed: boolean
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
buildArgs: string
|
||||
}
|
||||
|
||||
/** Complete project-file template artifact. */
|
||||
export class TemplateArtifact<TModel extends object> extends TextProjectFile {
|
||||
/** Render and own one complete project file. */
|
||||
constructor(relativePath: string, template: TextTemplate<TModel>, model: TModel) {
|
||||
super(relativePath, template.render(model))
|
||||
}
|
||||
}
|
||||
|
||||
const README_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('README.md.tpl')
|
||||
const PACKAGE_JSON_TEMPLATE = loadHelperTemplate<{
|
||||
name: string
|
||||
description: string
|
||||
dependencies: string
|
||||
devDependencies: string
|
||||
}>('package.json.tpl')
|
||||
const INDEX_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('index.ts.tpl')
|
||||
const TSDOWN_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('tsdown.config.ts.tpl')
|
||||
const TSCONFIG_BASE_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('tsconfig.base.json.tpl')
|
||||
const GITIGNORE_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('gitignore.tpl')
|
||||
const YARNRC_TEMPLATE = loadHelperTemplate<ProjectTemplateContext>('yarnrc.yml.tpl')
|
||||
|
||||
/** Build the template model for one project and selected run interface. */
|
||||
export function createProjectTemplateContext(
|
||||
profile: ProjectProfile,
|
||||
runInterface: RunInterface = profile.runInterface,
|
||||
): ProjectTemplateContext {
|
||||
return {
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
releaseVersion: profile.releaseVersion,
|
||||
model: profile.runtime.model,
|
||||
modelLiteral: JSON.stringify(profile.runtime.model),
|
||||
isAcp: runInterface === 'acp',
|
||||
isEmbed: runInterface === 'embed',
|
||||
packageManager: profile.packageManager.name,
|
||||
installArgs: profile.packageManager.installCommand().join(' '),
|
||||
buildArgs: profile.packageManager.buildCommand().join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the complete root package defaults before structured contributions merge. */
|
||||
export function createPackageJsonDoc(context: ProjectTemplateContext): PackageJsonFile {
|
||||
const npmDependencies = baselineNpmDependencies(context.releaseVersion)
|
||||
return PackageJsonFile.create(PACKAGE_JSON_TEMPLATE.render({
|
||||
name: JSON.stringify(context.name),
|
||||
description: JSON.stringify(context.description),
|
||||
dependencies: JSON.stringify(npmDependencies.dependencies),
|
||||
devDependencies: JSON.stringify(npmDependencies.devDependencies),
|
||||
}))
|
||||
}
|
||||
|
||||
/** Build interface-independent one-shot project artifacts. */
|
||||
export function createBaselineProjectArtifacts(
|
||||
context: ProjectTemplateContext,
|
||||
): TemplateArtifact<ProjectTemplateContext>[] {
|
||||
return [
|
||||
new TemplateArtifact('tsdown.config.ts', TSDOWN_TEMPLATE, context),
|
||||
new TemplateArtifact('tsconfig.base.json', TSCONFIG_BASE_TEMPLATE, context),
|
||||
new TemplateArtifact('.gitignore', GITIGNORE_TEMPLATE, context),
|
||||
...context.packageManager === 'yarn'
|
||||
? [new TemplateArtifact('.yarnrc.yml', YARNRC_TEMPLATE, context)]
|
||||
: [],
|
||||
]
|
||||
}
|
||||
|
||||
/** Build files owned by the selected app feature option. */
|
||||
export function createAppProjectArtifacts(
|
||||
context: ProjectTemplateContext,
|
||||
): TemplateArtifact<ProjectTemplateContext>[] {
|
||||
return [
|
||||
new TemplateArtifact('README.md', README_TEMPLATE, context),
|
||||
new TemplateArtifact('index.ts', INDEX_TEMPLATE, context),
|
||||
]
|
||||
}
|
||||
|
||||
/** Build package scripts owned by the selected app feature option. */
|
||||
export function createAppPackageScripts(): Readonly<Record<'dev' | 'start', string>> {
|
||||
return {
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
start: 'dsh-sdk start index.js',
|
||||
}
|
||||
}
|
||||
19
packages/scaffold/helper/src/templates/template-assets.ts
Normal file
19
packages/scaffold/helper/src/templates/template-assets.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Asset loader for templates owned by dsh-helper.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/templates/template-assets
|
||||
*/
|
||||
|
||||
import { TextTemplate } from './text-template.ts'
|
||||
|
||||
/**
|
||||
* Load one helper-owned template in source and bundled layouts.
|
||||
* @param filename - basename under the helper template asset directory.
|
||||
* @returns compiled typed template.
|
||||
*/
|
||||
export function loadHelperTemplate<TModel extends object>(filename: string): TextTemplate<TModel> {
|
||||
if (filename.includes('/') || filename.includes('\\')) {
|
||||
throw new Error(`helper template filename must not contain a directory: ${filename}`)
|
||||
}
|
||||
return TextTemplate.fromFile<TModel>(new URL(`./assets/${filename}`, import.meta.url))
|
||||
}
|
||||
44
packages/scaffold/helper/src/templates/text-template.ts
Normal file
44
packages/scaffold/helper/src/templates/text-template.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Strict typed rendering for package-owned text templates.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/templates/text-template
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import Handlebars from 'handlebars'
|
||||
|
||||
/** Strict Handlebars template with no HTML escaping or custom extensions. */
|
||||
export class TextTemplate<TModel extends object> {
|
||||
private readonly renderer: Handlebars.TemplateDelegate<TModel>
|
||||
|
||||
/**
|
||||
* Compile one template under the SDK's fixed rendering policy.
|
||||
* @param source - complete template source.
|
||||
*/
|
||||
constructor(source: string) {
|
||||
const handlebars = Handlebars.create()
|
||||
this.renderer = handlebars.compile<TModel>(source, {
|
||||
strict: true,
|
||||
noEscape: true,
|
||||
preventIndent: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a template asset owned by the calling package.
|
||||
* @param url - source or bundled asset URL.
|
||||
* @returns compiled template.
|
||||
*/
|
||||
static fromFile<T extends object>(url: URL): TextTemplate<T> {
|
||||
return new TextTemplate<T>(readFileSync(url, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render text from one complete typed model.
|
||||
* @param model - values referenced by the template.
|
||||
* @returns rendered text.
|
||||
*/
|
||||
render(model: TModel): string {
|
||||
return this.renderer(model)
|
||||
}
|
||||
}
|
||||
409
packages/scaffold/helper/tests/documents.spec.ts
Normal file
409
packages/scaffold/helper/tests/documents.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
|
||||
import { EnvFile } from '../src/documents/env-file.ts'
|
||||
import { PackageJsonFile } from '../src/documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../src/documents/pnpm-workspace-file.ts'
|
||||
import { TsConfigFile } from '../src/documents/tsconfig-file.ts'
|
||||
import { TextProjectFile, withTrailingNewline } from '../src/documents/project-file.ts'
|
||||
import { featureId, resourceKey } from '../src/ids.ts'
|
||||
import { LinkWorkspace } from '../src/package-managers/link-workspace.ts'
|
||||
import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts'
|
||||
import {
|
||||
NpmPackageManager,
|
||||
NodeCommandRunner,
|
||||
PnpmPackageManager,
|
||||
YarnPackageManager,
|
||||
createPackageManager,
|
||||
inferPackageManagerName,
|
||||
probePackageManagerVersion,
|
||||
scrubEnvironment,
|
||||
type CommandRunner,
|
||||
} from '../src/package-managers/package-manager.ts'
|
||||
import { createBaselineProjectArtifacts } from '../src/templates/project-template.ts'
|
||||
import { loadHelperTemplate } from '../src/templates/template-assets.ts'
|
||||
import { TextTemplate } from '../src/templates/text-template.ts'
|
||||
import { resolveNpmDependency } from '../src/project/npm-dependency-policy.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('structured project documents', () => {
|
||||
it('normalizes trailing newlines and preserves managed package fields', () => {
|
||||
expect(withTrailingNewline('a\n\n')).toBe('a\n')
|
||||
const manifest = PackageJsonFile.parse('{"name":"demo","custom":1,"dependencies":{"z":"1"}}\n')
|
||||
manifest.setScript('start', 'node index.js')
|
||||
manifest.setNpmDependency('dependencies', 'a', '2')
|
||||
manifest.setNpmDependency('devDependencies', 'typescript', '3')
|
||||
manifest.removeNpmDependency('dependencies', 'z')
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.setPackageManager('pnpm@10.0.0')
|
||||
manifest.setResolution('a', 'portal:../a')
|
||||
manifest.validate()
|
||||
expect(manifest.npmDependency('a')).toEqual({ section: 'dependencies', spec: '2' })
|
||||
expect(manifest.npmDependencyNames()).toEqual(['a', 'typescript'])
|
||||
expect(JSON.parse(manifest.serialize())).toMatchObject({
|
||||
name: 'demo',
|
||||
custom: 1,
|
||||
dependencies: { a: '2' },
|
||||
workspaces: ['plugins/*'],
|
||||
})
|
||||
manifest.setPackageManager(undefined)
|
||||
expect(manifest.value().packageManager).toBeUndefined()
|
||||
expect(() => PackageJsonFile.parse('[]')).toThrow('root must be an object')
|
||||
expect(() => PackageJsonFile.parse('{')).toThrow('invalid package.json')
|
||||
for (const [text, diagnostic] of [
|
||||
['{}', 'name must be'],
|
||||
['{"name":"x","scripts":null}', 'scripts must be an object'],
|
||||
['{"name":"x","dependencies":[]}', 'dependencies must be an object'],
|
||||
['{"name":"x","devDependencies":{"bad":""}}', 'must be a non-empty string'],
|
||||
['{"name":"x","workspaces":"bad"}', 'workspaces must be an array'],
|
||||
] as const) expect(() => { PackageJsonFile.parse(text).validate() }).toThrow(diagnostic)
|
||||
const minimal = PackageJsonFile.parse('{"name":"x"}')
|
||||
minimal.validate()
|
||||
expect(minimal.npmDependency('missing')).toBeUndefined()
|
||||
expect(minimal.serialize()).toBe('{\n "name": "x"\n}\n')
|
||||
expect(PackageJsonFile.parse('{"name":"x","devDependencies":{"a":"1"}}').npmDependencyNames()).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('round-trips Cordis comments and !!js while editing owned fields', () => {
|
||||
const created = CordisYamlFile.create().clone()
|
||||
created.addEntry({ id: 'created', name: 'created-package' }, `Uncomment this example.
|
||||
config:
|
||||
value: true`)
|
||||
expect(created.serialize()).toMatch(/^- id: created/m)
|
||||
expect(created.serialize()).toContain(' # Uncomment this example.\n # config:\n # value: true')
|
||||
expect(created.serialize()).not.toMatch(/^\[/)
|
||||
const flow = CordisYamlFile.parse('[{ id: flow, name: flow-package, config: { root: ./flow } }]\n')
|
||||
expect(flow.serialize()).toContain('- id: flow\n name: flow-package\n config:\n root: ./flow')
|
||||
expect(flow.serialize()).not.toContain('{')
|
||||
const document = CordisYamlFile.parse(`# lead
|
||||
- id: provider
|
||||
name: 'provider-package'
|
||||
config:
|
||||
endpoint: !!js process.env.PROVIDER_URL
|
||||
custom: keep
|
||||
`)
|
||||
const endpoint = document.entry('provider')?.config?.endpoint
|
||||
expect(endpoint).toBeInstanceOf(JsExpression)
|
||||
document.updateOwnedConfig('provider', ['endpoint'], { endpoint: new JsExpression('process.env.NEXT_URL') })
|
||||
document.setDisabled('provider', true)
|
||||
document.addEntry({ id: 'tool', name: 'demo-tool' })
|
||||
document.validate()
|
||||
const text = document.serialize()
|
||||
expect(text).toContain('# lead')
|
||||
expect(text).toContain('!!js process.env.NEXT_URL')
|
||||
expect(text).toContain('custom: keep')
|
||||
expect(document.removeEntry('tool')).toBe(true)
|
||||
expect(document.removeEntry('tool')).toBe(false)
|
||||
document.setDisabled('provider', false)
|
||||
expect(document.entry('provider')?.disabled).toBeUndefined()
|
||||
expect(() => { document.addEntry({ id: 'provider', name: 'duplicate' }) }).toThrow('already exists')
|
||||
expect(() => CordisYamlFile.parse('{}')).toThrow('root must be a sequence')
|
||||
})
|
||||
|
||||
it('rejects malformed Cordis config entries and missing mutation targets', () => {
|
||||
expect(() => new JsExpression(' ')).toThrow('must not be empty')
|
||||
expect(() => CordisYamlFile.parse('[')).toThrow('invalid cordis.yml')
|
||||
for (const [text, diagnostic] of [
|
||||
['- nope\n', 'every entry must be a mapping'],
|
||||
['- name: pkg\n', 'id must be'],
|
||||
['- id: x\n', 'name must be'],
|
||||
['- id: x\n name: pkg\n config: nope\n', 'config must be'],
|
||||
['- id: x\n name: pkg\n disabled: nope\n', 'disabled must be'],
|
||||
] as const) expect(() => CordisYamlFile.parse(text).entries()).toThrow(diagnostic)
|
||||
const duplicate = CordisYamlFile.parse('- id: x\n name: one\n- id: x\n name: two\n')
|
||||
expect(() => { duplicate.validate() }).toThrow('duplicate Cordis config entry id')
|
||||
const document = CordisYamlFile.create()
|
||||
expect(() => { document.setDisabled('missing', true) }).toThrow('does not exist')
|
||||
expect(() => { document.updateOwnedConfig('missing', [], {}) }).toThrow('does not exist')
|
||||
document.addEntry({ id: 'plain', name: 'pkg' })
|
||||
document.updateOwnedConfig('plain', [], { value: 1 })
|
||||
expect(document.entry('plain')?.config).toEqual({ value: 1 })
|
||||
document.updateOwnedConfig('plain', ['value'], {})
|
||||
expect(document.entry('plain')?.config).toBeUndefined()
|
||||
const scalar = CordisYamlFile.parse('- id: plain\n name: pkg\n config: value\n')
|
||||
expect(() => { scalar.updateOwnedConfig('plain', [], {}) }).toThrow('config is not a mapping')
|
||||
expect(() => { CordisYamlFile.parse('- nope\n').setDisabled('missing', true) }).toThrow('does not exist')
|
||||
})
|
||||
|
||||
it('keeps .env append-only while managing .env.example strictly', () => {
|
||||
const document = EnvFile.parse('.env', '# keep\nA=1\nexport B=2\n')
|
||||
expect(document.get('A')).toBe('1')
|
||||
expect(document.get('B')).toBe('2')
|
||||
expect(document.append('A', 'next')).toBe(false)
|
||||
expect(document.append('C', '', 'Required')).toBe(true)
|
||||
expect(document.serialize()).toBe('# keep\nA=1\nexport B=2\n# Required\nC=\n')
|
||||
expect(() => { document.append('bad-name', 'x') }).toThrow('invalid environment variable')
|
||||
expect(() => { document.append('D', '', '') }).toThrow('non-empty line')
|
||||
expect(() => { document.append('D', '', 'bad\ncomment') }).toThrow('non-empty line')
|
||||
expect(() => { document.set('A', 'next') }).toThrow('.env is append-only')
|
||||
expect(() => { document.remove('A') }).toThrow('.env is append-only')
|
||||
const duplicateEnv = EnvFile.parse('.env', 'A=1\nA=2\n')
|
||||
expect(duplicateEnv.get('A')).toBe('2')
|
||||
expect(duplicateEnv.append('A', 'next')).toBe(false)
|
||||
expect(() => { duplicateEnv.validate() }).not.toThrow()
|
||||
|
||||
const example = EnvFile.parse('.env.example', '# keep\nA=1\n')
|
||||
example.set('A', 'next')
|
||||
example.set('C', '')
|
||||
example.remove('C')
|
||||
expect(example.serialize()).toBe('# keep\nA=next\n')
|
||||
expect(() => { example.append('C', '') }).toThrow('.env.example is SDK-managed')
|
||||
expect(() => { example.set('bad-name', 'x') }).toThrow('invalid environment variable')
|
||||
const duplicate = EnvFile.parse('.env.example', 'A=1\nA=2\n')
|
||||
expect(() => { duplicate.validate() }).toThrow('duplicate variable A')
|
||||
expect(() => duplicate.get('A')).toThrow('duplicate variable A')
|
||||
expect(() => { duplicate.set('A', 'next') }).toThrow('duplicate variable A')
|
||||
expect(() => { duplicate.remove('A') }).toThrow('duplicate variable A')
|
||||
example.remove('missing')
|
||||
expect(document.get('missing')).toBeUndefined()
|
||||
expect(EnvFile.parse('.env', '').clone().serialize()).toBe('\n')
|
||||
})
|
||||
|
||||
it('patches JSONC references without erasing comments', () => {
|
||||
const document = TsConfigFile.parse(`{
|
||||
// retained
|
||||
"references": [{ "path": "./plugins/a" }]
|
||||
}`)
|
||||
document.addReference('./plugins/a')
|
||||
document.addReference('./plugins/b')
|
||||
document.validate()
|
||||
expect(document.serialize()).toContain('// retained')
|
||||
expect(document.serialize()).toContain('./plugins/b')
|
||||
expect(() => TsConfigFile.parse('{')).toThrow('valid JSONC object')
|
||||
const malformed = TsConfigFile.parse('{"references": {}}')
|
||||
expect(() => { malformed.addReference('./plugins/x') }).toThrow('must be an array')
|
||||
const badItem = TsConfigFile.parse('{"references":[null]}')
|
||||
expect(() => { badItem.addReference('./plugins/x') }).toThrow('must contain')
|
||||
expect(() => { badItem.validate() }).toThrow('must contain')
|
||||
const created = TsConfigFile.create()
|
||||
created.validate()
|
||||
expect(created.clone().serialize()).toContain('"references": []')
|
||||
TsConfigFile.parse('{}').validate()
|
||||
const noReferences = TsConfigFile.parse('{}')
|
||||
noReferences.addReference('./plugin')
|
||||
expect(noReferences.serialize()).toContain('./plugin')
|
||||
expect(() => { TsConfigFile.parse('{"references":{}}').validate() }).toThrow('must be an array')
|
||||
})
|
||||
|
||||
it('creates and parses pnpm workspace policy', () => {
|
||||
const document = PnpmWorkspaceFile.create()
|
||||
document.addPackage('plugins/*')
|
||||
document.addPackage('plugins/*')
|
||||
document.disableAutoInstallPeers()
|
||||
document.validate()
|
||||
expect(document.serialize()).toContain('autoInstallPeers: false')
|
||||
const parsed = PnpmWorkspaceFile.parse(document.serialize())
|
||||
expect(parsed.clone().serialize()).toBe(document.serialize())
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: [')).toThrow('invalid pnpm-workspace.yaml')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: nope')).toThrow('packages must be an array')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: [{}]')).toThrow('packages must be an array')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: [1]')).toThrow('packages must be an array')
|
||||
expect(() => PnpmWorkspaceFile.parse('[]')).toThrow('root must be an object')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: []\nautoInstallPeers: nope')).toThrow('must be boolean')
|
||||
const invalid = PnpmWorkspaceFile.create()
|
||||
invalid.addPackage(' ')
|
||||
expect(() => { invalid.validate() }).toThrow('must not be empty')
|
||||
expect(PnpmWorkspaceFile.parse('packages: []\n').serialize()).not.toContain('autoInstallPeers')
|
||||
const preserved = PnpmWorkspaceFile.parse(`# keep workspace settings
|
||||
packages:
|
||||
- apps/*
|
||||
catalog:
|
||||
react: ^19.0.0
|
||||
overrides:
|
||||
legacy: modern
|
||||
`)
|
||||
preserved.disableAutoInstallPeers()
|
||||
const preservedText = preserved.clone().serialize()
|
||||
expect(preservedText).toContain('# keep workspace settings')
|
||||
expect(preservedText).toContain('catalog:\n react: ^19.0.0')
|
||||
expect(preservedText).toContain('overrides:\n legacy: modern')
|
||||
expect(preservedText).toContain('autoInstallPeers: false')
|
||||
})
|
||||
|
||||
it('renders strict complete-file templates without escaping code text', () => {
|
||||
const template = new TextTemplate<{ value: string }>('value={{value}} missing={{missing}}')
|
||||
expect(() => template.render({ value: '<code>' })).toThrow()
|
||||
const valid = new TextTemplate<{ value: string }>('value={{value}}')
|
||||
expect(valid.render({ value: '<code>' })).toBe('value=<code>')
|
||||
expect(new TextTemplate<Record<string, never>>('\\{{model}}').render({})).toBe('{{model}}')
|
||||
expect(() => new TextProjectFile('/absolute', 'x')).toThrow('stay inside')
|
||||
expect(() => new TextProjectFile('../outside', 'x')).toThrow('stay inside')
|
||||
expect(new TextProjectFile('inside', 'x').clone().serialize()).toBe('x\n')
|
||||
expect(() => featureId('Bad Id')).toThrow('invalid feature id')
|
||||
expect(() => resourceKey('')).toThrow('must not be empty')
|
||||
expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
|
||||
expect(createBaselineProjectArtifacts({
|
||||
name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
|
||||
isAcp: false, isEmbed: true,
|
||||
installArgs: 'install', buildArgs: 'build',
|
||||
}).map(document => document.relativePath)).toContain('.yarnrc.yml')
|
||||
expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name')
|
||||
expect(new LocalPluginBlueprint('tool', 'tool').packageName('@scope/project')).toBe('@scope/project-tool')
|
||||
expect(new LocalPluginBlueprint('tool', 'tool').packageName('@invalid')).toBe('@invalid-tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('package manager strategies', () => {
|
||||
it('owns workspace fields, execution commands, and supported version floors', () => {
|
||||
const npm = new NpmPackageManager('10.1.0')
|
||||
const pnpm = new PnpmPackageManager('10.2.0')
|
||||
const yarn = new YarnPackageManager('4.0.0')
|
||||
for (const manager of [npm, pnpm, yarn]) manager.validateVersion()
|
||||
expect(npm.localPluginSpec()).toBe('*')
|
||||
expect(npm.linkSpec('../x')).toBe('file:../x')
|
||||
expect(npm.configureWorkspace(PackageJsonFile.create('{"name":"demo"}'))).toEqual([])
|
||||
const pnpmManifest = PackageJsonFile.create('{"name":"demo"}')
|
||||
expect(pnpm.configureWorkspace(pnpmManifest)[0]).toBeInstanceOf(PnpmWorkspaceFile)
|
||||
expect(pnpm.localPluginSpec()).toBe('workspace:*')
|
||||
expect(pnpm.linkSpec('../x')).toBe('link:../x')
|
||||
const yarnManifest = PackageJsonFile.create('{"name":"demo"}')
|
||||
expect(yarn.configureWorkspace(yarnManifest)).toEqual([])
|
||||
expect(yarn.localPluginSpec()).toBe('workspace:*')
|
||||
expect(yarn.linkSpec('../x')).toBe('portal:../x')
|
||||
expect(yarn.buildCommand()).toEqual(['build'])
|
||||
expect(npm.installCommand()).toEqual(['install'])
|
||||
expect(npm.buildCommand()).toEqual(['run', 'build'])
|
||||
expect(() => createPackageManager('npm', '9.0.0')).toThrow('npm >=10')
|
||||
expect(() => createPackageManager('pnpm', '9.0.0')).toThrow('pnpm >=10')
|
||||
expect(() => createPackageManager('yarn', '1.22.0')).toThrow('Yarn >=2')
|
||||
expect(inferPackageManagerName(undefined, 'pnpm/10.0.0 node/v24')).toBe('pnpm')
|
||||
expect(inferPackageManagerName(undefined, 'unknown/1')).toBeUndefined()
|
||||
expect(inferPackageManagerName('yarn', undefined)).toBe('yarn')
|
||||
expect(() => createPackageManager('npm', 'invalid')).toThrow('invalid package manager version')
|
||||
expect(resolveNpmDependency('cordis', 'devDependencies', '0.0.1')).toEqual({
|
||||
section: 'devDependencies', spec: '^4.0.0-rc.7',
|
||||
})
|
||||
expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15')
|
||||
expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2')
|
||||
expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3')
|
||||
expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project')
|
||||
})
|
||||
|
||||
it('checks install/build process outcomes and scrubs credential-shaped names', async () => {
|
||||
const calls: string[][] = []
|
||||
const runner: CommandRunner = {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
return { exitCode: 0, signal: null }
|
||||
},
|
||||
}
|
||||
const npm = new NpmPackageManager('10.0.0')
|
||||
await npm.install('/tmp', runner)
|
||||
await npm.build('/tmp', runner)
|
||||
expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']])
|
||||
await npm.add('some-pkg@1.0.0', '/tmp', runner)
|
||||
const pnpm = createPackageManager('pnpm', '10.0.0')
|
||||
await pnpm.add('github:o/r#sha', '/tmp', runner)
|
||||
expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0'])
|
||||
expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha'])
|
||||
const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
|
||||
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
|
||||
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
|
||||
await expect(npm.build('/tmp', killed)).rejects.toThrow('killed by SIGTERM')
|
||||
expect(scrubEnvironment({
|
||||
PATH: '/bin',
|
||||
API_KEY: 'secret',
|
||||
DB_PASSWORD: 'secret',
|
||||
TOKEN_VALUE: 'secret',
|
||||
})).toEqual({ PATH: '/bin' })
|
||||
})
|
||||
|
||||
it('probes versions and runs real child-process boundaries', async () => {
|
||||
await expect(probePackageManagerVersion('npm', process.cwd())).resolves.toMatch(/^\d+/)
|
||||
await expect(probePackageManagerVersion('npm', '/missing/dsh-cwd')).rejects.toThrow('cannot run npm --version')
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-empty-version-'))
|
||||
temporary.push(root)
|
||||
const executable = join(root, 'npm')
|
||||
await writeFile(executable, '#!/bin/sh\nexit 0\n')
|
||||
await chmod(executable, 0o755)
|
||||
const before = process.env.PATH
|
||||
process.env.PATH = root
|
||||
await expect(probePackageManagerVersion('npm', root)).rejects.toThrow('empty version output')
|
||||
process.env.PATH = before
|
||||
const runner = new NodeCommandRunner()
|
||||
await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
|
||||
let redirected = ''
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
|
||||
})
|
||||
const redirecting = new NodeCommandRunner(output)
|
||||
await expect(redirecting.run(
|
||||
process.execPath,
|
||||
['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
|
||||
root,
|
||||
)).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
expect(redirected).toContain('child-out')
|
||||
expect(redirected).toContain('child-err')
|
||||
await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('discovers and rewrites a repository-local NPM dependency closure', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-link-workspace-'))
|
||||
temporary.push(root)
|
||||
await mkdir(join(root, 'vendor', 'cordis'), { recursive: true })
|
||||
await mkdir(join(root, 'packages', 'sdk', 'scripts'), { recursive: true })
|
||||
await mkdir(join(root, 'packages', 'sdk', 'helper'), { recursive: true })
|
||||
await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: 'cordis' }))
|
||||
await writeFile(join(root, 'packages', 'sdk', 'helper', 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-helper' }))
|
||||
await writeFile(join(root, 'packages', 'sdk', 'scripts', 'package.json'), JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { cordis: '^4' },
|
||||
}))
|
||||
const workspace = await LinkWorkspace.open(root)
|
||||
expect(workspace.closure(['@deepseek-ai/dsh-scripts'])).toEqual([
|
||||
'@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', 'cordis',
|
||||
])
|
||||
const manifest = PackageJsonFile.create('{"name":"consumer","description":"test"}')
|
||||
manifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
const pnpmWorkspace = PnpmWorkspaceFile.create()
|
||||
workspace.apply(join(root, 'consumer'), manifest, new PnpmPackageManager('10.0.0'), [pnpmWorkspace])
|
||||
expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/)
|
||||
expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
|
||||
expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
|
||||
expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')
|
||||
expect(workspace.packageDirectory('missing')).toBeUndefined()
|
||||
const yarnManifest = PackageJsonFile.create('{"name":"consumer"}')
|
||||
yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), [])
|
||||
expect(yarnManifest.value().resolutions).toBeDefined()
|
||||
const pnpmManifest = PackageJsonFile.create('{"name":"consumer"}')
|
||||
pnpmManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
expect(() => { workspace.apply(join(root, 'consumer-pnpm'), pnpmManifest, new PnpmPackageManager('10.0.0'), []) })
|
||||
.toThrow('requires pnpm-workspace.yaml')
|
||||
})
|
||||
|
||||
it('rejects malformed linked repositories', async () => {
|
||||
const missing = await mkdtemp(join(tmpdir(), 'dsh-link-missing-'))
|
||||
temporary.push(missing)
|
||||
await mkdir(join(missing, 'vendor'), { recursive: true })
|
||||
await mkdir(join(missing, 'packages'), { recursive: true })
|
||||
await expect(LinkWorkspace.open(missing)).rejects.toThrow('not a DeepSeek Harness repository root')
|
||||
const unreadable = await mkdtemp(join(tmpdir(), 'dsh-link-unreadable-'))
|
||||
temporary.push(unreadable)
|
||||
await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true })
|
||||
await mkdir(join(unreadable, 'packages'), { recursive: true })
|
||||
await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('cannot read linked package')
|
||||
const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-'))
|
||||
temporary.push(unnamed)
|
||||
await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true })
|
||||
await mkdir(join(unnamed, 'packages'), { recursive: true })
|
||||
await writeFile(join(unnamed, 'vendor', 'unnamed', 'package.json'), '{}')
|
||||
await expect(LinkWorkspace.open(unnamed)).rejects.toThrow('not a DeepSeek Harness repository root')
|
||||
const duplicate = await mkdtemp(join(tmpdir(), 'dsh-link-duplicate-'))
|
||||
temporary.push(duplicate)
|
||||
await mkdir(join(duplicate, 'vendor', 'one'), { recursive: true })
|
||||
await mkdir(join(duplicate, 'packages', 'group', 'two'), { recursive: true })
|
||||
await writeFile(join(duplicate, 'vendor', 'one', 'package.json'), '{"name":"duplicate"}')
|
||||
await writeFile(join(duplicate, 'packages', 'group', 'two', 'package.json'), '{"name":"duplicate"}')
|
||||
await expect(LinkWorkspace.open(duplicate)).rejects.toThrow('duplicate linked package name')
|
||||
})
|
||||
})
|
||||
95
packages/scaffold/helper/tests/headless-prompt-port.spec.ts
Normal file
95
packages/scaffold/helper/tests/headless-prompt-port.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
|
||||
|
||||
/** Unwrap an answered outcome or fail the test. */
|
||||
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
|
||||
const outcome = await promise
|
||||
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
describe('HeadlessPromptError', () => {
|
||||
it('names the unanswered prompt', () => {
|
||||
const error = new HeadlessPromptError('DeepSeek API key')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('HeadlessPromptError')
|
||||
expect(error.prompt).toBe('DeepSeek API key')
|
||||
expect(error.message).toContain('DeepSeek API key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessPromptPort', () => {
|
||||
const port = new HeadlessPromptPort()
|
||||
|
||||
describe('text', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
|
||||
})
|
||||
|
||||
it('falls back to the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
|
||||
})
|
||||
|
||||
it('prefers the initial value over the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
|
||||
})
|
||||
|
||||
it('fails loud when no default exists', async () => {
|
||||
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
|
||||
it('fails loud when the default is invalid', async () => {
|
||||
await expect(port.text({
|
||||
message: 'name',
|
||||
defaultValue: '',
|
||||
validate: value => value.length === 0 ? 'required' : undefined,
|
||||
})).rejects.toThrow(/required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('secret', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiselect', () => {
|
||||
it('returns the initial values', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('returns an empty selection when none are supplied and none are required', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud when required and nothing is preselected', async () => {
|
||||
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nestedMultiselect', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
})
|
||||
999
packages/scaffold/helper/tests/project.spec.ts
Normal file
999
packages/scaffold/helper/tests/project.spec.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FeatureOption,
|
||||
ExclusiveOptionFeature,
|
||||
MultiOptionFeature,
|
||||
FixedFeature,
|
||||
type FeatureProjectView,
|
||||
} from '../src/features/feature.ts'
|
||||
import { createBuiltinRegistry } from '../src/features/builtin/index.ts'
|
||||
import {
|
||||
npmCordisConfigEntry,
|
||||
cordisConfigEntry,
|
||||
environment as environmentResource,
|
||||
optionalString,
|
||||
ownedTextFile,
|
||||
requiredString,
|
||||
stringArray,
|
||||
} from '../src/features/builtin/helpers.ts'
|
||||
import { defineFeatures, defineFeature } from '../src/features/define-feature.ts'
|
||||
import { FeatureRegistry } from '../src/features/registry.ts'
|
||||
import { ProjectContribution } from '../src/features/resources.ts'
|
||||
import type { CordisConfigEntryResource, ProjectResource } from '../src/features/resources.ts'
|
||||
import type { CordisConfigEntry } from '../src/documents/cordis-yaml-file.ts'
|
||||
import { PackageJsonFile } from '../src/documents/package-json-file.ts'
|
||||
import { TextProjectFile } from '../src/documents/project-file.ts'
|
||||
import { featureId, resourceKey } from '../src/ids.ts'
|
||||
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
|
||||
import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts'
|
||||
import { SdkProject } from '../src/project/sdk-project.ts'
|
||||
import type {
|
||||
FeatureSelection,
|
||||
ProjectCreationRequest,
|
||||
ProjectProfile,
|
||||
} from '../src/project/types.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
function selection(id: string, options: readonly string[], secrets?: Record<string, string>): FeatureSelection {
|
||||
return { id: featureId(id), options, ...secrets ? { secrets } : {} }
|
||||
}
|
||||
|
||||
function request(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
bash: 'local' | 'sandbox' = 'local',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
name: 'test-agent',
|
||||
description: 'test project',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
selection('provider', ['deepseek-official'], { apiKey: 'test-key' }),
|
||||
selection('bash', [bash]),
|
||||
selection('app', [app]),
|
||||
selection('persistence', ['jsonl']),
|
||||
...extra,
|
||||
],
|
||||
localPlugins: plugins,
|
||||
}
|
||||
}
|
||||
|
||||
async function createCommitted(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-project-domain-'))
|
||||
temporary.push(root)
|
||||
const creation = request(extra, plugins)
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
for (const plugin of plugins) edit.addPlugin(plugin)
|
||||
return (await edit.commit()).project
|
||||
}
|
||||
|
||||
describe('SdkProject and ProjectEditSession', () => {
|
||||
it('derives existing-project profiles and tolerates malformed optional documents', async () => {
|
||||
const make = async (
|
||||
name: string,
|
||||
manifest: Record<string, unknown>,
|
||||
cordis: string,
|
||||
extras: Record<string, string> = {},
|
||||
): Promise<SdkProject> => {
|
||||
const root = await mkdtemp(join(tmpdir(), `${name}-`))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify(manifest))
|
||||
await writeFile(join(root, 'cordis.yml'), cordis)
|
||||
for (const [path, text] of Object.entries(extras)) await writeFile(join(root, path), text)
|
||||
return SdkProject.open(root)
|
||||
}
|
||||
const acp = await make('dsh-open-acp', {
|
||||
name: 'acp', description: 'ACP', packageManager: 'pnpm@10.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-scripts': '^1.2.3' },
|
||||
}, `- id: acp
|
||||
name: '@deepseek-ai/dsh-acp'
|
||||
config: { model: app-model }
|
||||
`, { '.env': 'KEY=value\n', 'tsconfig.json': '{bad', 'pnpm-workspace.yaml': 'bad' })
|
||||
expect(acp.profile).toMatchObject({
|
||||
runInterface: 'acp', runtime: { model: 'app-model' }, releaseVersion: '1.2.3', description: 'ACP',
|
||||
})
|
||||
expect(acp.profile.packageManager.name).toBe('pnpm')
|
||||
expect(acp.readEnvironment('.env', 'KEY')).toBe('value')
|
||||
expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow()
|
||||
expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile)
|
||||
await expect(make('dsh-open-tui', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
await expect(make('dsh-open-tui-subpath', {}, `- id: tui-prompt
|
||||
name: '@deepseek-ai/dsh-tui/prompt'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
const embedded = await make('dsh-open-embed', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
`, { 'yarn.lock': '' })
|
||||
expect(embedded.profile.runInterface).toBe('embed')
|
||||
expect(embedded.profile.runtime.model).toBe('provider-model')
|
||||
expect(embedded.profile.packageManager.name).toBe('yarn')
|
||||
expect(embedded.profile.name).toBe(embedded.root.split('/').at(-1))
|
||||
const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' })
|
||||
expect(pnpm.profile.packageManager.name).toBe('pnpm')
|
||||
const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n')
|
||||
expect(defaults.profile).toMatchObject({
|
||||
runInterface: 'embed', runtime: { model: 'deepseek-v4-flash' }, releaseVersion: '0.0.1',
|
||||
})
|
||||
expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app')
|
||||
await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n'))
|
||||
.rejects.toThrow('invalid packageManager field')
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [fallback-model] }
|
||||
`)
|
||||
expect(providerFallback.profile.runtime.model).toBe('fallback-model')
|
||||
const pnpmRequest = { ...request(), packageManager: new (await import('../src/package-managers/package-manager.ts')).PnpmPackageManager('10.0.0') }
|
||||
expect(SdkProject.create(join(defaults.root, 'pnpm'), pnpmRequest).hasDocument('pnpm-workspace.yaml')).toBe(true)
|
||||
})
|
||||
|
||||
it('commits a complete blueprint and round-trips every installed feature', async () => {
|
||||
const project = await createCommitted([
|
||||
selection('hmr', ['default']),
|
||||
selection('fs', ['local']),
|
||||
selection('todo', ['default']),
|
||||
selection('web', ['exa'], { apiKey: 'exa-key' }),
|
||||
selection('subagent', ['fork']),
|
||||
selection('workflow', ['workerthread']),
|
||||
selection('hooks', ['claude', 'codex']),
|
||||
], [new LocalPluginBlueprint('sample', 'plugin'), new LocalPluginBlueprint('lookup', 'tool')])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const inspections = registry.inspect(project)
|
||||
expect(inspections.filter(item => item.state === 'enabled').map(item => item.id)).toEqual([
|
||||
'provider', 'spine', 'bash', 'app', 'persistence', 'hmr', 'fs', 'todo', 'web', 'subagent', 'workflow', 'hooks',
|
||||
])
|
||||
expect(inspections.find(item => item.id === 'subagent')?.options).toEqual(['spawn', 'fork'])
|
||||
expect(project.cordisConfigEntries().map(entry => entry.id)).toContain('lookup')
|
||||
const index = await readFile(join(project.root, 'index.ts'), 'utf8')
|
||||
expect(index).toContain('SdkBootContext')
|
||||
expect(index).toContain('agents.create')
|
||||
expect(index).not.toContain('boot.args.resume')
|
||||
expect(index).not.toContain('AgentId')
|
||||
expect(index).toContain('SessionId(`main-session-${randomUUID()}`)')
|
||||
expect(project.packageManifest().scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
build: 'dsh-sdk build',
|
||||
typecheck: 'tsc -b',
|
||||
start: 'dsh-sdk start index.js',
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
|
||||
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
|
||||
expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
|
||||
expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant')
|
||||
expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant')
|
||||
expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}')
|
||||
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2')
|
||||
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15')
|
||||
expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1')
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant')
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin')
|
||||
expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
|
||||
expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey')
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
|
||||
})
|
||||
|
||||
it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => {
|
||||
const project = await createCommitted([selection('subagent', [option])])
|
||||
expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local')
|
||||
expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks')
|
||||
expect(project.packageManifest().dependencies).toMatchObject({
|
||||
'@deepseek-ai/dsh-tasks-local': '^0.0.1',
|
||||
'@deepseek-ai/dsh-tool-tasks': '^0.0.1',
|
||||
})
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('round-trips embed app projects without a front-door Cordis config entry', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'embed')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
const committed = (await edit.commit()).project
|
||||
const app = createBuiltinRegistry(committed.profile).get(featureId('app')).inspect(committed)
|
||||
expect(app).toMatchObject({ state: 'enabled', options: ['embed'] })
|
||||
expect(app.selection).toEqual(selection('app', ['embed']))
|
||||
expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(committed.cordis.entry('acp')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits the sandbox workspace-write example as inactive Cordis config', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'embed', 'sandbox')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await edit.commit()
|
||||
const cordis = await readFile(join(root, 'cordis.yml'), 'utf8')
|
||||
expect(cordis).toContain(`- id: bash
|
||||
name: "@deepseek-ai/dsh-bash-sandbox"
|
||||
# Uncomment to allow writes under the project workspace.
|
||||
# config:
|
||||
# mode: workspace-write
|
||||
# workspaceRoot: !!js process.cwd()`)
|
||||
})
|
||||
|
||||
it('round-trips the custom pi-ai provider with explicit endpoint and default model', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-'))
|
||||
temporary.push(root)
|
||||
const base = request()
|
||||
const creation: ProjectCreationRequest = {
|
||||
...base,
|
||||
features: [
|
||||
{
|
||||
id: featureId('provider'),
|
||||
options: ['custom'],
|
||||
values: { baseURL: 'https://custom.example/v1' },
|
||||
secrets: { apiKey: 'custom-key' },
|
||||
},
|
||||
...base.features.filter(item => item.id !== 'provider'),
|
||||
],
|
||||
}
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
const committed = (await edit.commit()).project
|
||||
expect(committed.cordis.entry('llm-pi-ai')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: { baseURL: 'https://custom.example/v1' },
|
||||
})
|
||||
expect(committed.cordis.entry('llm-pi-ai')?.config).not.toHaveProperty('models')
|
||||
expect(createBuiltinRegistry(committed.profile).get(featureId('provider')).inspect(committed)).toMatchObject({
|
||||
state: 'enabled', options: ['custom'],
|
||||
})
|
||||
})
|
||||
|
||||
it('switches exclusive options and refuses disabling a required feature', async () => {
|
||||
const project = await createCommitted([selection('subagent', ['spawn']), selection('workflow', ['workerthread'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
const persistence = registry.get(featureId('persistence'))
|
||||
edit.configureFeature(persistence, selection('persistence', ['sqlite']))
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('subagent'))) }).toThrow('required by workflow')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('app'))) }).toThrow('required feature')
|
||||
const committed = await edit.commit()
|
||||
expect(committed.project.cordis.entry('session-persistence')?.name).toContain('sqlite')
|
||||
expect(committed.changes.npmDependenciesChanged).toBe(true)
|
||||
})
|
||||
|
||||
it('switches app-owned files and scripts while protecting user edits', async () => {
|
||||
const project = await createCommitted()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
|
||||
const acp = (await edit.commit()).project
|
||||
expect(acp.profile.runInterface).toBe('acp')
|
||||
expect(acp.cordis.entry('commands')).toBeUndefined()
|
||||
expect(acp.cordis.entry('user-interaction')).toBeUndefined()
|
||||
expect(acp.packageManifest().scripts).toMatchObject({
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
start: 'dsh-sdk start index.js',
|
||||
})
|
||||
expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP automation server')
|
||||
expect(await readFile(join(acp.root, 'index.ts'), 'utf8')).not.toContain('agents.create')
|
||||
|
||||
const acpRegistry = createBuiltinRegistry(acp.profile)
|
||||
const embedEdit = acp.edit(acpRegistry)
|
||||
embedEdit.configureFeature(acpRegistry.get(featureId('app')), selection('app', ['embed']))
|
||||
const embed = (await embedEdit.commit()).project
|
||||
expect(embed.profile.runInterface).toBe('embed')
|
||||
expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness')
|
||||
const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8')
|
||||
expect(embedIndex).toContain('agents.create')
|
||||
expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'")
|
||||
expect(embedIndex).not.toContain('AgentId')
|
||||
|
||||
await writeFile(join(embed.root, 'README.md'), '# Custom README\n')
|
||||
const modified = await SdkProject.open(embed.root)
|
||||
const modifiedRegistry = createBuiltinRegistry(modified.profile)
|
||||
expect(() => { modified.edit(modifiedRegistry).configureFeature(
|
||||
modifiedRegistry.get(featureId('app')),
|
||||
selection('app', ['acp']),
|
||||
) }).toThrow('feature-owned file was modified: README.md')
|
||||
|
||||
const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8'))
|
||||
manifest.removeScript('dev')
|
||||
await writeFile(join(embed.root, 'package.json'), manifest.serialize())
|
||||
const incomplete = await SdkProject.open(embed.root)
|
||||
expect(createBuiltinRegistry(incomplete.profile).get(featureId('app')).inspect(incomplete).diagnostics)
|
||||
.toContain('missing package.json script dev')
|
||||
})
|
||||
|
||||
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
expect(edit.inspections()).not.toHaveLength(0)
|
||||
const todo = registry.get(featureId('todo'))
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
edit.disableFeature(todo)
|
||||
edit.configureFeature(todo, selection('todo', ['default']))
|
||||
edit.enableFeature(todo)
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.setCustomPluginDisabled('missing', true) }).toThrow('does not exist')
|
||||
const committed = await edit.commit()
|
||||
expect(committed.changes.enabledFeatures).toContain('todo')
|
||||
expect(() => { edit.enableFeature(todo) }).toThrow('already committed')
|
||||
})
|
||||
|
||||
it('preserves custom entries and toggles only their Loader disabled state', async () => {
|
||||
const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.setCustomPluginDisabled('sample', true)
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true)
|
||||
expect(() => { edit.setCustomPluginDisabled('agent-loop', true) }).toThrow('builtin feature')
|
||||
const next = (await edit.commit()).project
|
||||
const enable = next.edit(createBuiltinRegistry(next.profile))
|
||||
enable.setCustomPluginDisabled('sample', false)
|
||||
expect((await enable.commit()).project.cordis.entry('sample')?.disabled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects local plugin collisions and invalid optional document shapes', async () => {
|
||||
const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const npmDependencyConflict = project.edit(registry)
|
||||
expect(() => { npmDependencyConflict.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) })
|
||||
.toThrow('NPM dependency already exists')
|
||||
await writeFile(join(project.root, 'tsconfig.json'), 'not-json\n')
|
||||
const malformed = await SdkProject.open(project.root)
|
||||
expect(() => { malformed.edit(createBuiltinRegistry(malformed.profile)).addPlugin(
|
||||
new LocalPluginBlueprint('other', 'plugin'),
|
||||
) }).toThrow('requires a valid tsconfig')
|
||||
const entryProject = await createCommitted()
|
||||
const entryEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile))
|
||||
;(entryEdit as unknown as { cordis(): { addEntry(entry: CordisConfigEntry): void } }).cordis()
|
||||
.addEntry({ id: 'sample', name: 'manual' })
|
||||
expect(() => { entryEdit.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) }).toThrow('entry already exists')
|
||||
const fileEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile))
|
||||
;(fileEdit as unknown as { documents: Map<string, TextProjectFile> }).documents
|
||||
.set('plugins/other/package.json', new TextProjectFile('plugins/other/package.json', '{}'))
|
||||
expect(() => { fileEdit.addPlugin(new LocalPluginBlueprint('other', 'plugin')) }).toThrow('file already exists')
|
||||
})
|
||||
|
||||
it('reinstalls existing/disabled features and detects requirement cycles', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default']), selection('subagent', ['spawn'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
const todo = registry.get(featureId('todo'))
|
||||
edit.installFeature(todo, selection('todo', ['default']))
|
||||
edit.disableFeature(todo)
|
||||
edit.installFeature(todo, selection('todo', ['default']))
|
||||
const subagent = registry.get(featureId('subagent'))
|
||||
edit.disableFeature(subagent)
|
||||
edit.installFeature(registry.get(featureId('workflow')), selection('workflow', ['workerthread']))
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'subagent-spawn')?.disabled).toBeUndefined()
|
||||
|
||||
class Cyclic extends FixedFeature {
|
||||
override readonly summary = 'cyclic'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override readonly id
|
||||
override readonly requires
|
||||
constructor(id: string, required: string) {
|
||||
super()
|
||||
this.id = featureId(id)
|
||||
this.requires = [featureId(required)]
|
||||
}
|
||||
}
|
||||
const one = new Cyclic('cycle-one', 'cycle-two')
|
||||
const two = new Cyclic('cycle-two', 'cycle-one')
|
||||
const cycleRegistry = new FeatureRegistry([one, two], project.profile)
|
||||
expect(() => { project.edit(cycleRegistry).installFeature(one, selection('cycle-one', ['one'])) })
|
||||
.toThrow('cyclic feature requirement')
|
||||
})
|
||||
|
||||
it('removes clean owned files and detects files disappearing before commit', async () => {
|
||||
const project = await createCommitted([selection('hooks', ['claude', 'codex']), selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const remove = project.edit(registry)
|
||||
remove.configureFeature(registry.get(featureId('hooks')), selection('hooks', ['codex']))
|
||||
const committed = await remove.commit()
|
||||
expect(committed.changes.changedFiles).toContain('hooks.json')
|
||||
const edit = committed.project.edit(createBuiltinRegistry(committed.project.profile))
|
||||
edit.disableFeature(createBuiltinRegistry(committed.project.profile).get(featureId('todo')))
|
||||
await rm(join(committed.project.root, 'cordis.yml'))
|
||||
await expect(edit.commit()).rejects.toThrow('cannot verify project file cordis.yml')
|
||||
})
|
||||
|
||||
it('guards internal resource collisions and malformed aggregate documents', async () => {
|
||||
const project = await createCommitted()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
type Internals = {
|
||||
documents: Map<string, TextProjectFile>
|
||||
states: Map<ReturnType<typeof featureId>, unknown>
|
||||
applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void
|
||||
removeResource(resource: ProjectResource): void
|
||||
replaceContribution(previous: ProjectContribution | undefined, next: ProjectContribution): void
|
||||
finalProfile(): ProjectProfile
|
||||
manifest(): unknown
|
||||
cordis(): unknown
|
||||
environment(path: '.env' | '.env.example'): unknown
|
||||
state(feature: FixedFeature): unknown
|
||||
}
|
||||
const internals = edit as unknown as Internals
|
||||
const collidingEntry: ProjectResource = {
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:agent-loop'),
|
||||
entry: { id: 'agent-loop', name: 'other-package' }, ownedConfigKeys: [],
|
||||
}
|
||||
expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by')
|
||||
const existingFile: ProjectResource = {
|
||||
kind: 'owned-file', key: resourceKey('file:tsconfig.json'),
|
||||
document: new TextProjectFile('tsconfig.json', 'replacement'), removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
expect(() => { internals.applyResource(existingFile, undefined) }).toThrow('already exists')
|
||||
internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'old'))
|
||||
const previousFile: ProjectResource = {
|
||||
...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'old'),
|
||||
}
|
||||
const nextFile: ProjectResource = {
|
||||
...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'replacement'),
|
||||
}
|
||||
internals.applyResource(nextFile, previousFile)
|
||||
expect(internals.documents.get('owned.txt')?.serialize()).toBe('replacement\n')
|
||||
internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'user edit'))
|
||||
expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('was modified')
|
||||
const existingScript: ProjectResource = {
|
||||
kind: 'package-script', key: resourceKey('package-script:build'),
|
||||
name: 'build', command: 'other build', removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
expect(() => { internals.applyResource(existingScript, undefined) }).toThrow('script already exists')
|
||||
const transientScript: ProjectResource = {
|
||||
kind: 'package-script', key: resourceKey('package-script:transient'),
|
||||
name: 'transient', command: 'first', removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
internals.applyResource(transientScript, undefined)
|
||||
const nextScript: ProjectResource = { ...transientScript, command: 'second' }
|
||||
internals.applyResource(nextScript, transientScript)
|
||||
internals.applyResource(nextScript, transientScript)
|
||||
;(internals.manifest() as PackageJsonFile).setScript('transient', 'user edit')
|
||||
expect(() => { internals.applyResource(transientScript, nextScript) }).toThrow('script was modified')
|
||||
expect(() => { internals.removeResource(nextScript) }).toThrow('script was modified')
|
||||
;(internals.manifest() as PackageJsonFile).setScript('transient', 'second')
|
||||
internals.removeResource(nextScript)
|
||||
expect(() => { internals.removeResource(nextScript) }).toThrow('script is missing')
|
||||
expect(() => { internals.removeResource({
|
||||
...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'),
|
||||
}) }).toThrow('owned file is missing')
|
||||
expect(() => { internals.removeResource({
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:missing'),
|
||||
entry: { id: 'missing', name: 'missing' }, ownedConfigKeys: [],
|
||||
}) }).toThrow('cannot confirm old Cordis resource')
|
||||
const transient: ProjectResource = {
|
||||
kind: 'owned-file', key: resourceKey('file:transient.txt'),
|
||||
document: new TextProjectFile('transient.txt', 'transient'), removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
internals.applyResource(transient, undefined)
|
||||
internals.removeResource(transient)
|
||||
internals.replaceContribution(
|
||||
new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: 'cordis', section: 'dependencies' }]),
|
||||
new ProjectContribution([{
|
||||
kind: 'cordis-config-entry', key: resourceKey('shared'), entry: { id: 'new', name: 'new' }, ownedConfigKeys: [],
|
||||
}]),
|
||||
)
|
||||
internals.replaceContribution(
|
||||
new ProjectContribution([{
|
||||
kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'old', exampleValue: '',
|
||||
}]),
|
||||
new ProjectContribution([{
|
||||
kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'new', exampleValue: '',
|
||||
}]),
|
||||
)
|
||||
internals.documents.set('.env', new TextProjectFile('.env', 'bad'))
|
||||
expect(() => edit.readEnvironment('.env', 'KEY')).toThrow('not an environment document')
|
||||
expect(() => { internals.environment('.env') }).toThrow('not an environment document')
|
||||
internals.documents.delete('package.json')
|
||||
expect(() => { internals.manifest() }).toThrow('package.json is missing')
|
||||
internals.documents.delete('cordis.yml')
|
||||
expect(() => { internals.cordis() }).toThrow('cordis.yml is missing')
|
||||
class Foreign extends FixedFeature {
|
||||
override readonly id = featureId('foreign')
|
||||
override readonly summary = 'foreign'
|
||||
override readonly options = []
|
||||
}
|
||||
expect(() => { internals.state(new Foreign()) }).toThrow('not applicable')
|
||||
internals.states.delete(featureId('app'))
|
||||
expect(internals.finalProfile()).toBe(project.profile)
|
||||
const sourceDocuments = (project as unknown as { documents: Map<string, TextProjectFile> }).documents
|
||||
sourceDocuments.set('.env', new TextProjectFile('.env', 'bad'))
|
||||
expect(() => project.readEnvironment('.env', 'KEY')).toThrow('not an environment document')
|
||||
sourceDocuments.delete('package.json')
|
||||
expect(() => project.packageJson).toThrow('package.json is missing or invalid')
|
||||
sourceDocuments.delete('cordis.yml')
|
||||
expect(() => project.cordis).toThrow('cordis.yml is missing or invalid')
|
||||
})
|
||||
|
||||
it('rejects external edits before writing any affected file', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.disableFeature(registry.get(featureId('todo')))
|
||||
const manifestBefore = await readFile(join(project.root, 'package.json'), 'utf8')
|
||||
await writeFile(join(project.root, 'cordis.yml'), '# external\n[]\n')
|
||||
await expect(edit.commit()).rejects.toThrow('changed outside this edit session')
|
||||
expect(await readFile(join(project.root, 'package.json'), 'utf8')).toBe(manifestBefore)
|
||||
})
|
||||
|
||||
it('rejects a create target file that appeared after the edit session opened', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-create-conflict-'))
|
||||
temporary.push(root)
|
||||
const creation = request()
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await writeFile(join(root, 'README.md'), 'external\n')
|
||||
await expect(edit.commit()).rejects.toThrow('changed outside this edit session: README.md')
|
||||
})
|
||||
|
||||
it('uses Cordis config entries as the installation anchor and rejects partial resources', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-inconsistent-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'partial', dependencies: { '@deepseek-ai/dsh-llm-deepseek': '^0.0.1' },
|
||||
}))
|
||||
await writeFile(join(root, 'cordis.yml'), '[]\n')
|
||||
const project = await SdkProject.open(root)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
expect(registry.get(featureId('provider')).inspect(project)).toMatchObject({
|
||||
state: 'absent', diagnostics: [],
|
||||
})
|
||||
|
||||
const partialRoot = await mkdtemp(join(tmpdir(), 'dsh-entry-partial-'))
|
||||
temporary.push(partialRoot)
|
||||
await writeFile(join(partialRoot, 'package.json'), JSON.stringify({ name: 'partial-entry' }))
|
||||
await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
`)
|
||||
const partial = await SdkProject.open(partialRoot)
|
||||
const installation = createBuiltinRegistry(partial.profile)
|
||||
.get(featureId('provider')).inspect(partial)
|
||||
expect(installation.state).toBe('inconsistent')
|
||||
expect(installation.diagnostics).toContain('missing package.json dependencies entry @deepseek-ai/dsh-llm-deepseek')
|
||||
const partialEdit = partial.edit(createBuiltinRegistry(partial.profile))
|
||||
const provider = createBuiltinRegistry(partial.profile).get(featureId('provider'))
|
||||
expect(() => { partialEdit.configureFeature(provider, selection('provider', ['deepseek-official'])) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.enableFeature(provider) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.disableFeature(provider) }).toThrow('required feature')
|
||||
})
|
||||
|
||||
it('rejects inconsistent optional features and incompatible requirement options', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-optional-inconsistent-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), '{"name":"partial"}')
|
||||
await writeFile(join(root, 'cordis.yml'), `- id: web-search-exa
|
||||
name: '@deepseek-ai/dsh-web-search-exa'
|
||||
`)
|
||||
const project = await SdkProject.open(root)
|
||||
const builtin = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(builtin)
|
||||
const web = builtin.get(featureId('web'))
|
||||
expect(() => { edit.disableFeature(web) }).toThrow('inconsistent')
|
||||
expect(() => { edit.installFeature(web, selection('web', ['deepseek-official'])) }).toThrow('inconsistent')
|
||||
|
||||
class RequiresWeb extends FixedFeature {
|
||||
override readonly id = featureId('requires-web')
|
||||
override readonly summary = 'requires web'
|
||||
override readonly requires = [featureId('web')]
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
}
|
||||
const requiresWeb = new RequiresWeb()
|
||||
const webRegistry = new FeatureRegistry([web, requiresWeb], project.profile)
|
||||
expect(() => { project.edit(webRegistry).installFeature(requiresWeb, selection('requires-web', ['one'])) })
|
||||
.toThrow('required feature web is inconsistent')
|
||||
|
||||
class RequiresAcp extends FixedFeature {
|
||||
override readonly id = featureId('requires-acp')
|
||||
override readonly summary = 'requires acp'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override requirements(): readonly [{ id: ReturnType<typeof featureId>; options: readonly string[] }] {
|
||||
return [{ id: featureId('app'), options: ['acp'] }]
|
||||
}
|
||||
}
|
||||
const requiring = new RequiresAcp()
|
||||
const complete = await createCommitted()
|
||||
const app = createBuiltinRegistry(complete.profile).get(featureId('app'))
|
||||
const registry = new FeatureRegistry([app, requiring], complete.profile)
|
||||
expect(() => { complete.edit(registry).installFeature(requiring, selection('requires-acp', ['one'])) })
|
||||
.toThrow('does not satisfy the option requirement')
|
||||
})
|
||||
|
||||
it('removes obsolete environment resources when switching options', async () => {
|
||||
const project = await createCommitted([selection('web', ['exa'], { apiKey: 'exa' })])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
expect(edit.readEnvironment('.env.example', 'EXA_API_KEY')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves duplicate and existing .env values while appending differently named secrets', async () => {
|
||||
const project = await createCommitted()
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(project.root, '.env'))).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
const original = '# keep\nDEEPSEEK_API_KEY=first\nDEEPSEEK_API_KEY=second\n'
|
||||
await writeFile(join(project.root, '.env'), original)
|
||||
if (process.platform !== 'win32') await chmod(join(project.root, '.env'), 0o640)
|
||||
const reopened = await SdkProject.open(project.root)
|
||||
const registry = createBuiltinRegistry(reopened.profile)
|
||||
expect(registry.get(featureId('provider')).inspect(reopened)).toMatchObject({
|
||||
state: 'enabled', selection: { secrets: { apiKey: 'second' } },
|
||||
})
|
||||
const edit = reopened.edit(registry)
|
||||
edit.configureFeature(
|
||||
registry.get(featureId('provider')),
|
||||
selection('provider', ['deepseek-official'], { apiKey: 'replacement' }),
|
||||
)
|
||||
edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' }))
|
||||
const withExa = (await edit.commit()).project
|
||||
expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(withExa.root, '.env'))).mode & 0o777).toBe(0o640)
|
||||
}
|
||||
const nextRegistry = createBuiltinRegistry(withExa.profile)
|
||||
const remove = withExa.edit(nextRegistry)
|
||||
remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
await remove.commit()
|
||||
expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
|
||||
})
|
||||
|
||||
it('refuses to remove a feature-owned file after user edits', async () => {
|
||||
const project = await createCommitted([selection('hooks', ['claude', 'codex'])])
|
||||
await writeFile(join(project.root, 'hooks.json'), '{"hooks":{}}\n')
|
||||
const reopened = await SdkProject.open(project.root)
|
||||
const registry = createBuiltinRegistry(reopened.profile)
|
||||
const edit = reopened.edit(registry)
|
||||
expect(() => { edit.configureFeature(
|
||||
registry.get(featureId('hooks')),
|
||||
selection('hooks', ['codex']),
|
||||
) }).toThrow('owned file was modified: hooks.json')
|
||||
})
|
||||
|
||||
it('does not mistake a linked NPM dependency closure for an installed feature', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-'))
|
||||
temporary.push(root)
|
||||
const base = request([selection('hooks', ['claude'])])
|
||||
const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot }
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
const committed = (await edit.commit()).project
|
||||
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
|
||||
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
|
||||
})
|
||||
|
||||
it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-'))
|
||||
temporary.push(root)
|
||||
const creation = request()
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await edit.commit()
|
||||
const manifestPath = join(root, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record<string, string> }
|
||||
manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' }
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
const reopened = await SdkProject.open(root)
|
||||
const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile))
|
||||
edit2.addExternalPlugin('ext-plugin', 'ext-plugin')
|
||||
expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists')
|
||||
expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed')
|
||||
const commit = await edit2.commit()
|
||||
expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension points', () => {
|
||||
const profile: ProjectProfile = {
|
||||
name: 'test', description: 'test', runtime: { model: 'm' }, runInterface: 'embed',
|
||||
packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
it('rejects cross-feature resource ownership conflicts at registry construction', () => {
|
||||
class TestOption extends FeatureOption {
|
||||
override readonly id = 'default'
|
||||
override readonly label = 'Default'
|
||||
override contribution(): ProjectContribution {
|
||||
return new ProjectContribution([{
|
||||
kind: 'npm-dependency', key: resourceKey('npm-dependency:shared'), name: 'shared', section: 'dependencies',
|
||||
}])
|
||||
}
|
||||
}
|
||||
class TestFeature extends FixedFeature {
|
||||
override readonly id
|
||||
override readonly summary = 'test'
|
||||
override readonly options = [new TestOption()]
|
||||
constructor(id: string) {
|
||||
super()
|
||||
this.id = featureId(id)
|
||||
}
|
||||
}
|
||||
expect(() => new FeatureRegistry([
|
||||
new TestFeature('one'), new TestFeature('two'),
|
||||
], profile)).toThrow('declared by both one and two')
|
||||
expect(new TestFeature('one').defaultOptions()).toEqual(['default'])
|
||||
expect(() => new FeatureRegistry([
|
||||
new TestFeature('one'), new TestFeature('one'),
|
||||
], profile)).toThrow('duplicate feature id')
|
||||
})
|
||||
|
||||
it('validates selection modes and declarative feature definitions', () => {
|
||||
const option = { id: 'one', label: 'One', default: true, resources: [] } as const
|
||||
expect(() => defineFeature({ id: 'bad-single', summary: 'bad', mode: 'single', options: [] }))
|
||||
.toThrow('requires one default option')
|
||||
expect(() => defineFeature({
|
||||
id: 'bad-exclusive', summary: 'bad', mode: 'exclusive', options: [{ ...option, default: false }],
|
||||
})).toThrow('exactly one default option')
|
||||
expect(() => defineFeature({
|
||||
id: 'bad-multiple', summary: 'bad', mode: 'multiple', options: [{ ...option, default: false }],
|
||||
})).toThrow('at least one default option')
|
||||
const exclusive = defineFeature({
|
||||
id: 'defined', summary: 'Defined', mode: 'exclusive', supportedInterfaces: ['embed'],
|
||||
requires: [{ id: 'base' }], suggests: ['suggested'],
|
||||
baseResources: [{ kind: 'npm-dependency', name: 'base', section: 'devDependencies' }],
|
||||
options: [
|
||||
{
|
||||
id: 'one', label: 'One', default: true,
|
||||
requires: [{ id: 'option', options: ['required'] }],
|
||||
secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }],
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry', id: 'one', package: 'one-package',
|
||||
config: { nested: { value: 1 }, list: ['x'], nullable: null },
|
||||
},
|
||||
{ kind: 'owned-file', path: 'one.txt', text: 'one', removeOnlyWhenUnchanged: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'two', label: 'Two', resources: [
|
||||
{ kind: 'file-cordis-config-entry', id: 'two', path: './two.ts' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(exclusive.defaultOptions(profile)).toEqual(['one'])
|
||||
expect(exclusive.isApplicable(profile)).toBe(true)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'acp' })).toBe(false)
|
||||
expect(exclusive.requirements(selection('defined', ['one']))).toEqual([
|
||||
{ id: 'base' }, { id: 'option', options: ['required'] },
|
||||
])
|
||||
const contribution = exclusive.contribution({
|
||||
id: featureId('defined'), options: ['one'], secrets: { token: 'secret' },
|
||||
}, profile)
|
||||
expect(contribution.resources.map(resource => resource.kind)).toEqual([
|
||||
'npm-dependency', 'npm-dependency', 'cordis-config-entry', 'owned-file', 'environment',
|
||||
])
|
||||
const entry = contribution.resources.find(resource => resource.kind === 'cordis-config-entry')
|
||||
expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([])
|
||||
expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3)
|
||||
expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'acp' }))
|
||||
.toThrow('not available')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one')
|
||||
expect(defineFeatures([exclusive, {
|
||||
id: 'fixed', summary: 'Fixed', mode: 'single', options: [option],
|
||||
}])).toHaveLength(2)
|
||||
expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature')
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'acp' }))
|
||||
.toBeUndefined()
|
||||
class Unsupported extends FixedFeature {
|
||||
override readonly id = featureId('unsupported')
|
||||
override readonly summary = 'unsupported'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override readonly supportedInterfaces = []
|
||||
}
|
||||
expect(() => new FeatureRegistry([new Unsupported()], profile)).toThrow('supports no run interface')
|
||||
})
|
||||
|
||||
it('covers feature base classes and resource conflict checks', () => {
|
||||
class EmptySimple extends FixedFeature {
|
||||
override readonly id = featureId('empty')
|
||||
override readonly summary = 'empty'
|
||||
override readonly options = []
|
||||
}
|
||||
expect(() => new EmptySimple().defaultOptions()).toThrow('has no option')
|
||||
class BadSimple extends FixedFeature {
|
||||
override readonly id = featureId('bad-simple')
|
||||
override readonly summary = 'bad'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})(), new (class extends FeatureOption {
|
||||
override readonly id = 'two'
|
||||
override readonly label = 'Two'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
}
|
||||
expect(() => new BadSimple().normalizeSelection(selection('bad-simple', ['one']), profile)).toThrow('one fixed option')
|
||||
class EmptyMulti extends MultiOptionFeature {
|
||||
override readonly id = featureId('multi')
|
||||
override readonly summary = 'multi'
|
||||
override readonly options = []
|
||||
override defaultOptions(): readonly string[] { return [] }
|
||||
}
|
||||
expect(() => new EmptyMulti().normalizeSelection(selection('multi', []), profile)).toThrow('at least one')
|
||||
class EmptyExclusive extends ExclusiveOptionFeature {
|
||||
override readonly id = featureId('exclusive')
|
||||
override readonly summary = 'exclusive'
|
||||
override readonly options = []
|
||||
override defaultOptions(): readonly string[] { return [] }
|
||||
}
|
||||
expect(() => new EmptyExclusive().normalizeSelection(selection('exclusive', []), profile)).toThrow('exactly one')
|
||||
const resource = {
|
||||
kind: 'npm-dependency' as const, key: resourceKey('same'), name: 'one', section: 'dependencies' as const,
|
||||
}
|
||||
expect(() => new ProjectContribution([resource, resource])).toThrow('duplicate contribution')
|
||||
expect(() => ProjectContribution.merge(
|
||||
new ProjectContribution([resource]),
|
||||
new ProjectContribution([{ ...resource, name: 'two' }]),
|
||||
)).toThrow('conflicting definitions')
|
||||
expect(ProjectContribution.merge(new ProjectContribution([resource]), new ProjectContribution([resource])).byKey().size)
|
||||
.toBe(1)
|
||||
expect(ownedTextFile('owner', 'file.txt', 'text').document).toBeInstanceOf(TextProjectFile)
|
||||
expect(optionalString({ value: 1 }, 'value')).toHaveLength(1)
|
||||
expect(optionalString({}, 'value')).toEqual([])
|
||||
expect(requiredString({ value: 'x' }, 'value')).toEqual([])
|
||||
expect(stringArray({ value: ['a'] }, 'value')).toEqual([])
|
||||
expect(stringArray({ value: [1] }, 'value')).toHaveLength(1)
|
||||
expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([])
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([])
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg')
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg')
|
||||
for (const invalid of ['', '@scope', '@scope/']) {
|
||||
expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier')
|
||||
}
|
||||
expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value')
|
||||
const builtins = createBuiltinRegistry(profile)
|
||||
expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed'])
|
||||
expect(builtins.get(featureId('hmr')).defaultOptions(profile)).toEqual(['default'])
|
||||
const app = builtins.get(featureId('app'))
|
||||
const acpEntry = builtins.get(featureId('app')).contribution(selection('app', ['acp']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp')
|
||||
expect(acpEntry?.entry.id).toBe('acp')
|
||||
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
||||
const embedOption = app.options.find(option => option.id === 'embed')
|
||||
expect(embedOption?.markerConfigEntries(profile)).toEqual([])
|
||||
expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([
|
||||
'owned-file', 'owned-file', 'package-script', 'package-script',
|
||||
])
|
||||
expect(embedOption?.matchesConfigEntries([
|
||||
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
||||
{ id: 'acp', name: '@deepseek-ai/dsh-acp' },
|
||||
], profile)).toBe(false)
|
||||
const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'agent-loop')
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: 'main' })).toEqual(['agents must be an array'])
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: ['main'] })).toEqual(['agents must be empty'])
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: [] })).toEqual([])
|
||||
expect(builtins.get(featureId('provider')).defaultOptions(profile)).toEqual(['deepseek-official'])
|
||||
expect(() => builtins.get(featureId('provider')).contribution({
|
||||
id: featureId('provider'), options: ['custom'], values: { baseURL: 1 },
|
||||
}, profile)).toThrow('baseURL must be a string')
|
||||
const alternateModel = builtins.get(featureId('provider')).contribution({
|
||||
id: featureId('provider'), options: ['deepseek-official'],
|
||||
}, { ...profile, runtime: { model: 'other' } }).resources
|
||||
.find(resource => resource.kind === 'cordis-config-entry')
|
||||
expect(alternateModel?.entry.config?.models).toEqual(['other'])
|
||||
class RequiringSimple extends BadSimple {
|
||||
override readonly requires = [featureId('npm-dependency')]
|
||||
}
|
||||
expect(new RequiringSimple().requirements(selection('bad-simple', ['one']))).toEqual([{ id: 'npm-dependency' }])
|
||||
expect(builtins.get(featureId('bash')).defaultOptions(profile)).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('reports every inconsistent feature resource shape', () => {
|
||||
const feature = defineFeature({
|
||||
id: 'inspectable', summary: 'Inspectable', mode: 'single',
|
||||
baseResources: [{ kind: 'file-cordis-config-entry', id: 'base', path: 'pkg' }],
|
||||
options: [{
|
||||
id: 'one', label: 'One', default: true,
|
||||
secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }],
|
||||
resources: [
|
||||
{ kind: 'file-cordis-config-entry', id: 'one', path: 'pkg', config: { value: 'x' } },
|
||||
{ kind: 'npm-dependency', name: 'dep' },
|
||||
{ kind: 'owned-file', path: 'owned.txt', text: 'owned' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const view = (entries: readonly CordisConfigEntry[]): FeatureProjectView => ({
|
||||
profile,
|
||||
cordisConfigEntries: () => entries,
|
||||
packageManifest: () => ({}),
|
||||
hasDocument: () => false,
|
||||
readEnvironment: (path) => {
|
||||
if (path === '.env.example') throw new Error('bad env')
|
||||
return 'secret'
|
||||
},
|
||||
})
|
||||
expect(feature.inspect(view([])).state).toBe('absent')
|
||||
const inconsistent = feature.inspect(view([
|
||||
{ id: 'one', name: 'pkg', config: { value: 1 } },
|
||||
{ id: 'extra', name: 'pkg', disabled: true },
|
||||
]))
|
||||
expect(inconsistent.state).toBe('inconsistent')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('missing Cordis config entry base')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('unexpected owned Cordis config entry extra')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('missing package.json dependencies entry dep')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('missing owned file owned.txt')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('bad env')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('mixed enabled states')
|
||||
expect(feature.inspect(view([{ id: 'unknown', name: 'pkg' }])).state).toBe('inconsistent')
|
||||
const ambiguous = defineFeature({
|
||||
id: 'ambiguous', summary: 'Ambiguous', mode: 'exclusive',
|
||||
options: [
|
||||
{ id: 'one', label: 'One', default: true, resources: [], markers: [{ id: 'one', name: 'pkg' }] },
|
||||
{ id: 'two', label: 'Two', resources: [], markers: [{ id: 'two', name: 'pkg' }] },
|
||||
],
|
||||
})
|
||||
expect(ambiguous.inspect(view([{ id: 'one', name: 'pkg' }, { id: 'two', name: 'pkg' }])).state)
|
||||
.toBe('inconsistent')
|
||||
const noValidator = defineFeature({
|
||||
id: 'no-validator', summary: 'No validator', mode: 'single',
|
||||
options: [{
|
||||
id: 'one', label: 'One', default: true,
|
||||
resources: [{ kind: 'file-cordis-config-entry', id: 'plain', path: 'plain-package' }],
|
||||
}],
|
||||
})
|
||||
expect(noValidator.inspect(view([{ id: 'plain', name: 'plain-package' }])).state).toBe('enabled')
|
||||
const app = createBuiltinRegistry(profile).get(featureId('app'))
|
||||
expect(app.inspect(view([{ id: 'acp', name: '@deepseek-ai/dsh-acp' }])).state)
|
||||
.toBe('inconsistent')
|
||||
})
|
||||
})
|
||||
484
packages/scaffold/helper/tests/questions.spec.ts
Normal file
484
packages/scaffold/helper/tests/questions.spec.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
import { S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_WARN } from '@clack/prompts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createBuiltinRegistry } from '../src/features/builtin/index.ts'
|
||||
import { FeatureConfigurator } from '../src/features/feature-configurator.ts'
|
||||
import { FeatureOption, ExclusiveOptionFeature } from '../src/features/feature.ts'
|
||||
import { ProjectContribution } from '../src/features/resources.ts'
|
||||
import { featureId } from '../src/ids.ts'
|
||||
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
|
||||
import { ClackPromptPort } from '../src/questions/clack-prompt-port.ts'
|
||||
import {
|
||||
PromptCancelledError,
|
||||
requireAnswer,
|
||||
type ConfirmPromptRequest,
|
||||
type MultiSelectPromptRequest,
|
||||
type NestedMultiSelectRequest,
|
||||
type NestedMultiSelectValue,
|
||||
type PromptOutcome,
|
||||
type PromptPort,
|
||||
type SecretPromptRequest,
|
||||
type SelectPromptRequest,
|
||||
type TextPromptRequest,
|
||||
} from '../src/questions/prompt-port.ts'
|
||||
import {
|
||||
ConfirmQuestion,
|
||||
MultiSelectQuestion,
|
||||
SecretQuestion,
|
||||
SelectQuestion,
|
||||
TextQuestion,
|
||||
} from '../src/questions/question.ts'
|
||||
import type { ProjectProfile } from '../src/project/types.ts'
|
||||
import { clackNestedMultiselect } from '../src/questions/clack-nested-multiselect.ts'
|
||||
|
||||
function validateString(
|
||||
outcome: PromptOutcome<string>,
|
||||
validate: ((value: string) => string | undefined) | undefined,
|
||||
): PromptOutcome<string> {
|
||||
if (outcome.status === 'answered') {
|
||||
const diagnostic = validate?.(outcome.value)
|
||||
if (diagnostic) throw new Error(diagnostic)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
class QueuePromptPort implements PromptPort {
|
||||
readonly answers: unknown[]
|
||||
readonly requests: string[] = []
|
||||
|
||||
constructor(answers: unknown[]) {
|
||||
this.answers = [...answers]
|
||||
}
|
||||
|
||||
next<T>(message: string): PromptOutcome<T> {
|
||||
this.requests.push(message)
|
||||
const value = this.answers.shift()
|
||||
return value === QueuePromptPort.cancel ? { status: 'cancelled' } : { status: 'answered', value: value as T }
|
||||
}
|
||||
|
||||
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return validateString(this.next<string>(request.message), request.validate)
|
||||
}
|
||||
|
||||
async secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return validateString(this.next<string>(request.message), request.validate)
|
||||
}
|
||||
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
|
||||
static readonly cancel = Symbol('cancel')
|
||||
}
|
||||
|
||||
describe('typed questions', () => {
|
||||
it('uses and validates prefilled answers without prompting', async () => {
|
||||
const port = new QueuePromptPort([])
|
||||
const text = new TextQuestion({ id: 'name', message: 'Name', validate: value => value ? undefined : 'required' })
|
||||
await expect(text.resolve(port, 'demo')).resolves.toEqual({ status: 'answered', value: 'demo' })
|
||||
await expect(text.resolve(port, '')).rejects.toThrow('name: required')
|
||||
const select = new SelectQuestion({
|
||||
id: 'choice', message: 'Choice', options: [{ value: 'a', label: 'A' }], initialValue: 'a',
|
||||
})
|
||||
await expect(select.resolve(port, 'b')).rejects.toThrow('unknown or disabled option')
|
||||
await expect(select.resolve(port, 'a')).resolves.toMatchObject({ value: 'a' })
|
||||
const disabled = new SelectQuestion({
|
||||
id: 'disabled', message: 'Disabled', options: [{ value: 'a', label: 'A', disabled: true }],
|
||||
})
|
||||
await expect(disabled.resolve(port, 'a')).rejects.toThrow('disabled option')
|
||||
const multi = new MultiSelectQuestion({
|
||||
id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], required: true,
|
||||
})
|
||||
await expect(multi.resolve(port, [])).rejects.toThrow('choose at least one')
|
||||
await expect(multi.resolve(port, ['missing'])).rejects.toThrow('unknown or disabled option')
|
||||
await expect(new MultiSelectQuestion({
|
||||
id: 'disabled-many', message: 'Disabled many', options: [{ value: 'a', label: 'A', disabled: true }],
|
||||
}).resolve(port, ['a'])).rejects.toThrow('disabled option')
|
||||
await expect(new MultiSelectQuestion({
|
||||
id: 'optional', message: 'Optional', options: [{ value: 'a', label: 'A' }],
|
||||
}).resolve(port, [])).resolves.toMatchObject({ value: [] })
|
||||
const secret = new SecretQuestion({ id: 'secret', message: 'Secret', validate: value => value ? undefined : 'required' })
|
||||
await expect(secret.resolve(port, 'value')).resolves.toMatchObject({ value: 'value' })
|
||||
await expect(secret.resolve(port, '')).rejects.toThrow('secret: required')
|
||||
await expect(new ConfirmQuestion({ id: 'confirm', message: 'Confirm' }).resolve(port, false))
|
||||
.resolves.toEqual({ status: 'answered', value: false })
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('delegates each interaction shape and propagates cancellation', async () => {
|
||||
const port = new QueuePromptPort(['text', 'secret', 'a', ['a'], true, QueuePromptPort.cancel])
|
||||
await expect(new TextQuestion({ id: 't', message: 'Text' }).resolve(port)).resolves.toMatchObject({ value: 'text' })
|
||||
await expect(new SecretQuestion({ id: 's', message: 'Secret' }).resolve(port)).resolves.toMatchObject({ value: 'secret' })
|
||||
await expect(new SelectQuestion({
|
||||
id: 'one', message: 'One', options: [{ value: 'a', label: 'A' }],
|
||||
}).resolve(port)).resolves.toMatchObject({ value: 'a' })
|
||||
await expect(new MultiSelectQuestion({
|
||||
id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }],
|
||||
}).resolve(port)).resolves.toMatchObject({ value: ['a'] })
|
||||
await expect(new ConfirmQuestion({ id: 'yes', message: 'Yes?' }).resolve(port)).resolves.toMatchObject({ value: true })
|
||||
const cancelled = await new ConfirmQuestion({ id: 'cancel', message: 'Cancel?' }).resolve(port)
|
||||
expect(() => requireAnswer(cancelled)).toThrow(PromptCancelledError)
|
||||
const optionsPort = new QueuePromptPort(['full', 'a', ['a']])
|
||||
await new TextQuestion({
|
||||
id: 'full', message: 'Full', placeholder: 'p', initialValue: 'i', defaultValue: 'd', validate: () => undefined,
|
||||
}).resolve(optionsPort)
|
||||
await new SelectQuestion({
|
||||
id: 'initial', message: 'Initial', options: [{ value: 'a', label: 'A' }], initialValue: 'a',
|
||||
}).resolve(optionsPort)
|
||||
await new MultiSelectQuestion({
|
||||
id: 'initial-many', message: 'Initial many', options: [{ value: 'a', label: 'A' }],
|
||||
initialValues: ['a'], required: true,
|
||||
}).resolve(optionsPort)
|
||||
})
|
||||
|
||||
it('accepts a visible placeholder default before required validation', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = new ClackPromptPort(input, output).text({
|
||||
message: 'Directory',
|
||||
placeholder: 'my-agent',
|
||||
defaultValue: 'my-agent',
|
||||
validate: value => value ? undefined : 'required',
|
||||
})
|
||||
setTimeout(() => input.write('\r'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'answered', value: 'my-agent' })
|
||||
})
|
||||
|
||||
it('renders warning confirmations with a yellow warning marker', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = new ClackPromptPort(input, output).confirm({
|
||||
message: 'Keep empty?',
|
||||
initialValue: true,
|
||||
tone: 'warning',
|
||||
})
|
||||
setTimeout(() => input.write('\r'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'answered', value: true })
|
||||
expect(stripVTControlCharacters(screen)).toContain(`${S_WARN} Keep empty?`)
|
||||
})
|
||||
|
||||
it('adapts secret, select, multiselect, nested, and cancellation prompts', async () => {
|
||||
const run = async <T>(
|
||||
start: (port: ClackPromptPort) => Promise<PromptOutcome<T>>,
|
||||
keys: string,
|
||||
): Promise<PromptOutcome<T>> => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = start(new ClackPromptPort(input, output))
|
||||
setTimeout(() => input.write(keys), 0)
|
||||
return pending
|
||||
}
|
||||
await expect(run(port => port.secret({ message: 'Secret', validate: value => value ? undefined : 'required' }), 'key\r'))
|
||||
.resolves.toEqual({ status: 'answered', value: 'key' })
|
||||
await expect(run(port => port.secret({ message: 'Secret' }), 'plain\r'))
|
||||
.resolves.toEqual({ status: 'answered', value: 'plain' })
|
||||
await expect(run(port => port.text({ message: 'Text', initialValue: 'seed' }), '\r'))
|
||||
.resolves.toEqual({ status: 'answered', value: 'seed' })
|
||||
let validated = 'unset'
|
||||
await expect(run(port => port.text({
|
||||
message: 'Empty', validate: (value) => { validated = value; return undefined },
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: '' })
|
||||
expect(validated).toBe('')
|
||||
await expect(run(port => port.select({
|
||||
message: 'Select', options: [{ value: 'a', label: 'A', hint: 'hint' }, { value: 'b', label: 'B', disabled: true }],
|
||||
initialValue: 'a',
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: 'a' })
|
||||
await expect(run(port => port.multiselect({
|
||||
message: 'Many', options: [{ value: 'a', label: 'A' }], initialValues: ['a'], required: true,
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: ['a'] })
|
||||
await expect(run(port => port.multiselect({
|
||||
message: 'Many', options: [{ value: 'a', label: 'A' }],
|
||||
}), ' \r')).resolves.toEqual({ status: 'answered', value: ['a'] })
|
||||
await expect(run(port => port.nestedMultiselect({
|
||||
message: 'Nested', options: [{ value: 'a', label: 'A', default: true }],
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: [{ value: 'a', choices: [] }] })
|
||||
await expect(run(port => port.confirm({ message: 'Cancel' }), '\u0003')).resolves.toEqual({ status: 'cancelled' })
|
||||
expect(new ClackPromptPort()).toBeInstanceOf(ClackPromptPort)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested Clack picker', () => {
|
||||
it('navigates root options, ignores disabled rows, and toggles optional rows', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', showChanges: true, input, output,
|
||||
options: [
|
||||
{ value: 'required', label: 'Required', required: true },
|
||||
{ value: 'optional', label: 'Optional', default: true },
|
||||
{ value: 'added', label: 'Added' },
|
||||
{ value: 'disabled', label: 'Disabled', disabled: true, warning: 'disabled warning' },
|
||||
],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[A \x1b[B\x1b[B \x1b[B \x1b[A\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered',
|
||||
value: [{ value: 'required', choices: [] }, { value: 'added', choices: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels from the root layer', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output, options: [{ value: 'one', label: 'One' }],
|
||||
})
|
||||
setTimeout(() => input.write('\u0003'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
|
||||
it('enters an exclusive child with Right and commits the selected option', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features',
|
||||
showChanges: true,
|
||||
input,
|
||||
output,
|
||||
options: [
|
||||
{
|
||||
value: 'persistence',
|
||||
label: 'Session storage',
|
||||
required: true,
|
||||
default: true,
|
||||
choiceMode: 'exclusive',
|
||||
choices: [
|
||||
{ value: 'jsonl', label: 'JSONL', default: true },
|
||||
{ value: 'sqlite', label: 'SQLite' },
|
||||
],
|
||||
},
|
||||
{ value: 'fs', label: 'Filesystem', default: true },
|
||||
],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[C\x1b[B\x1b[A\x1b[B\x1b[C\r\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered',
|
||||
value: [
|
||||
{ value: 'persistence', choices: ['sqlite'] },
|
||||
{ value: 'fs', choices: [] },
|
||||
],
|
||||
})
|
||||
const rendered = stripVTControlCharacters(screen)
|
||||
expect(rendered).toContain(`› ${S_CHECKBOX_SELECTED} Session storage`)
|
||||
expect(rendered).toContain(`› ${S_RADIO_ACTIVE} SQLite`)
|
||||
expect(rendered).toContain('● changed')
|
||||
})
|
||||
|
||||
it('highlights and blocks a selected multiple feature with no child option', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features',
|
||||
input,
|
||||
output,
|
||||
options: [{
|
||||
value: 'hooks',
|
||||
label: 'Hooks',
|
||||
default: true,
|
||||
choiceMode: 'multiple',
|
||||
choices: [
|
||||
{ value: 'claude', label: 'Claude', default: true },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[C \x1b[D \x1b[D\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered',
|
||||
value: [{ value: 'hooks', choices: ['claude'] }],
|
||||
})
|
||||
expect(stripVTControlCharacters(screen)).toContain('▲ choose at least one')
|
||||
})
|
||||
|
||||
it('blocks root submission for an exclusive feature with no selected option', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output,
|
||||
options: [{
|
||||
value: 'provider', label: 'Provider', default: true, choiceMode: 'exclusive',
|
||||
choices: [{ value: 'one', label: 'One' }],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\r\x1b[C\x1b[C\r\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered', value: [{ value: 'provider', choices: ['one'] }],
|
||||
})
|
||||
expect(stripVTControlCharacters(screen)).toContain('Choose one Provider option')
|
||||
})
|
||||
|
||||
it('blocks root submission for a multiple feature with no selected option', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output,
|
||||
options: [{
|
||||
value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple',
|
||||
choices: [{ value: 'one', label: 'One' }],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\r\x1b[C \r\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered', value: [{ value: 'hooks', choices: ['one'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders an unchanged checked option while another child is focused', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output,
|
||||
options: [{
|
||||
value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple',
|
||||
choices: [
|
||||
{ value: 'one', label: 'One', default: true },
|
||||
{ value: 'two', label: 'Two', default: true },
|
||||
],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[C\x1b[B\x1b[D\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered', value: [{ value: 'hooks', choices: ['one', 'two'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('submits an empty optional selection', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output, options: [{ value: 'one', label: 'One' }],
|
||||
})
|
||||
setTimeout(() => input.write('\r'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'answered', value: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('feature configurator', () => {
|
||||
const profile: ProjectProfile = {
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
runInterface: 'embed',
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
it('shares exclusive, multiple, fixed, and secret behavior', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek-official', 'new-key'])
|
||||
const configurator = new FeatureConfigurator(port)
|
||||
await expect(configurator.configure(registry.get(featureId('persistence')), profile)).resolves.toMatchObject({
|
||||
options: ['sqlite'],
|
||||
})
|
||||
await expect(configurator.configure(registry.get(featureId('subagent')), profile)).resolves.toMatchObject({
|
||||
options: ['spawn', 'fork'],
|
||||
})
|
||||
await expect(configurator.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old-key' } },
|
||||
)).resolves.toMatchObject({ secrets: { apiKey: 'new-key' } })
|
||||
expect(port.requests).toEqual([
|
||||
'Choose durable session storage',
|
||||
'Choose delegate work to child agents',
|
||||
'Choose model provider',
|
||||
'DeepSeek API key (leave empty to keep current)',
|
||||
])
|
||||
})
|
||||
|
||||
it('validates feature values, defaults, and retained secrets', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const fixed = new FeatureConfigurator(new QueuePromptPort([]))
|
||||
await expect(fixed.configure(registry.get(featureId('bash')), profile, undefined, ['local'])).resolves.toMatchObject({
|
||||
options: ['local'],
|
||||
})
|
||||
const requiredSecret = new FeatureConfigurator(new QueuePromptPort([]))
|
||||
await expect(requiredSecret.configure(
|
||||
registry.get(featureId('provider')), profile, undefined, ['deepseek-official'], { apiKey: '' },
|
||||
)).rejects.toThrow('required')
|
||||
const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek-official', '']))
|
||||
await expect(keep.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old' } },
|
||||
)).resolves.toMatchObject({ secrets: { apiKey: 'old' } })
|
||||
const custom = registry.get(featureId('provider'))
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom'])).configure(
|
||||
custom,
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['custom'], values: { baseURL: 1 }, secrets: { apiKey: 'old' } },
|
||||
)).rejects.toThrow('current value must be a string')
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom', ''])).configure(
|
||||
custom, profile, undefined,
|
||||
)).rejects.toThrow('required')
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom', 'https://next', ''])).configure(
|
||||
custom,
|
||||
profile,
|
||||
{
|
||||
id: featureId('provider'), options: ['custom'],
|
||||
values: { baseURL: 'https://old' }, secrets: { apiKey: 'old' },
|
||||
},
|
||||
)).resolves.toMatchObject({ values: { baseURL: 'https://next' }, secrets: { apiKey: 'old' } })
|
||||
class EmptyExclusive extends ExclusiveOptionFeature {
|
||||
override readonly id = featureId('empty-exclusive')
|
||||
override readonly summary = 'Empty'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override defaultOptions(): readonly string[] { return [] }
|
||||
}
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
|
||||
.rejects.toThrow('has no default option')
|
||||
})
|
||||
|
||||
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort([])
|
||||
const result = await new FeatureConfigurator(port).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'prefilled-key' },
|
||||
{ baseURL: 'https://prefilled' },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
options: ['custom'],
|
||||
values: { baseURL: 'https://prefilled' },
|
||||
secrets: { apiKey: 'prefilled-key' },
|
||||
})
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-string prefilled feature value', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'k' },
|
||||
{ baseURL: 123 },
|
||||
)).rejects.toThrow('must be a string')
|
||||
})
|
||||
})
|
||||
46
packages/scaffold/helper/tsconfig.json
Normal file
46
packages/scaffold/helper/tsconfig.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact-basic"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/hooks-claude"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/hooks-codex"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence-sqlite"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/tool-subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../web/tool-web"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
packages/scaffold/helper/tsdown.config.ts
Normal file
14
packages/scaffold/helper/tsdown.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Bundle helper runtime and mirror template assets beside the bundle. */
|
||||
export default defineConfig({
|
||||
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
|
||||
})
|
||||
6
packages/scaffold/protocol/README.i18n.yaml
Normal file
6
packages/scaffold/protocol/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/protocol/README.md
|
||||
README.md: dc42f385f6e7fd6327ca79887d3cf22d7c34ccdf
|
||||
README.zh.md: a292120be8b5b09aaadb05f9dbc272a51bd74831
|
||||
39
packages/scaffold/protocol/README.md
Normal file
39
packages/scaffold/protocol/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-sdk-protocol
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The package root enumerates the protocol consumer interface; source modules are not exported as deep imports. The server side is the [`dsh-jsonrpc`](../../scaffold/server/README.md) plugin; clients are [`dsh-sdk-client`](../client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
|
||||
|
||||
## Transport
|
||||
|
||||
`JsonRpcLineTransport` frames JSON-RPC 2.0 over caller-owned byte streams, one compact JSON frame per `\n`-terminated line. Frames with `id` and `method` are requests, `id` alone is a response, `method` alone is a notification; malformed JSON lines are ignored. `start()` attaches stream listeners, `close()` detaches them and rejects pending requests without destroying the streams. Missing request handlers answer `-32601`; handler rejections answer `-32603` with the error message. An error response rejects the pending `request()` with `JsonRpcResponseError`, which preserves the wire `code` and optional `data`. `JsonRpcTransportPeer` is the outbound surface (request/notify) the server class is typed against.
|
||||
|
||||
## Wire types
|
||||
|
||||
`types.ts` names every payload of the protocol served by `HarnessSdkServer`:
|
||||
|
||||
| Direction | Method | Types |
|
||||
|---|---|---|
|
||||
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) |
|
||||
| client→server | `shutdown` | no params → `{}` |
|
||||
| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) |
|
||||
| server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) |
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
|
||||
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package defines the client-facing wire protocol; the model-visible surfaces belong to the runtime plugins composed behind the serving [`dsh-jsonrpc`](../../scaffold/server/README.md) entry.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No protocol-version negotiation** — the handshake carries only `serverInfo.version` (`0.0.1`, unvalidated by clients); pre-release stance, no compatibility promise.
|
||||
- **No cancel or session-close methods** — a client abandons a turn by closing the runtime process; see the [`dsh-jsonrpc` README](../../scaffold/server/README.md).
|
||||
- **Server→client requests are dead capability** — the transport supports them, but the server never sends one; the Python SDK's responder surface exists for future approval flows.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user