Merge branch 'master' into codex/cli-one-shot-demo
This commit is contained in:
@@ -181,7 +181,7 @@ export interface Config extends LocalConfig {
|
||||
|
||||
Depends on: [`LocalConfig`](#deepseek-aidsh-bash-local) · [`SandboxMode`](core-data-structures/sandbox.md)
|
||||
|
||||
Source: [`packages/bash/bash-sandbox/src/index.ts:26`](../packages/bash/bash-sandbox/src/index.ts)
|
||||
Source: [`packages/bash/bash-sandbox/src/index.ts:27`](../packages/bash/bash-sandbox/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-cli-demo`
|
||||
|
||||
@@ -598,7 +598,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/sandbox/sandbox-local/src/index.ts:20`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
Source: [`packages/sandbox/sandbox-local/src/index.ts:19`](../packages/sandbox/sandbox-local/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-persistence-jsonl`
|
||||
|
||||
@@ -1301,13 +1301,16 @@ Abstract service classes — a deployment loads a concrete implementation packag
|
||||
|
||||
Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
|
||||
- `@deepseek-ai/create-sdk` ([`packages/sdk/create-sdk/src/index.ts`](../packages/sdk/create-sdk/src/index.ts))
|
||||
- `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
|
||||
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
|
||||
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
|
||||
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
|
||||
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
|
||||
- `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
|
||||
- `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
|
||||
@@ -78,12 +78,14 @@
|
||||
| block | 块 | | | |
|
||||
| build target | 构建目标 | | | |
|
||||
| cancel | 取消 | | | |
|
||||
| capability | 能力 | | | |
|
||||
| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |
|
||||
| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |
|
||||
| checkpoint | 检查点 | | | |
|
||||
| chunk | 分片 | | | |
|
||||
| compaction | 压缩 | 压缩(compaction) | | |
|
||||
| companion tool | 配套工具 | | | |
|
||||
| config | 配置 | | | |
|
||||
| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |
|
||||
| config key | 配置键 | | | Cordis 插件配置中的单个字段 |
|
||||
| consumer | 消费方 | | | |
|
||||
| content block | 内容块 | | | |
|
||||
| Cookbook | 实操手册 | | | 文档标题用语 |
|
||||
@@ -91,10 +93,13 @@
|
||||
| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指"另一侧"时可写「另一侧」 |
|
||||
| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |
|
||||
| contract | 契约 | | | 如:`pairing contract` →`配对契约` |
|
||||
| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |
|
||||
| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |
|
||||
| coverage | 覆盖率 | | | |
|
||||
| crash recovery | 崩溃恢复 | | | |
|
||||
| deploy root | 部署根目录 | | | |
|
||||
| durability | 持久性 | | | |
|
||||
| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |
|
||||
| enforcement frontier | 执行红线 | | 强制边界 | i18n 配对机制用语:manifest `required` 清单所划的门禁生效范围;与金标样例(style-samples ⑦)一致 |
|
||||
| event | 事件 | | | |
|
||||
| event log | 事件日志 | | | |
|
||||
@@ -123,6 +128,7 @@
|
||||
| mod | 模组 | | | |
|
||||
| model provider | 模型提供方 | | | |
|
||||
| module | 模块 | | | |
|
||||
| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |
|
||||
| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |
|
||||
| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |
|
||||
| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |
|
||||
|
||||
@@ -125,6 +125,10 @@ flowchart TD
|
||||
pkg_sandbox["sandbox"]
|
||||
pkg_sandbox_local["sandbox-local"]
|
||||
end
|
||||
subgraph group_sdk["packages/sdk"]
|
||||
pkg_helper["helper"]
|
||||
pkg_scripts["scripts"]
|
||||
end
|
||||
subgraph group_tasks["packages/tasks"]
|
||||
pkg_tasks["tasks"]
|
||||
pkg_tool_tasks["tool-tasks"]
|
||||
@@ -136,6 +140,8 @@ flowchart TD
|
||||
end
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_code_runtime_worker --> pkg_code_runtime
|
||||
pkg_helper --> pkg_brand
|
||||
pkg_scripts --> pkg_app_boot
|
||||
pkg_llm_deepseek --> pkg_llm
|
||||
pkg_llm_pi_ai --> pkg_llm
|
||||
pkg_session --> pkg_brand
|
||||
@@ -389,6 +395,8 @@ flowchart TD
|
||||
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | — |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) |
|
||||
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand) |
|
||||
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`llm`](../packages/llm/llm) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`llm`](../packages/llm/llm) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
|
||||
@@ -13,6 +13,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Interactive side sessions and merge-back](proposed/feature/2026-07-08-interactive-side-sessions.md) | 2026-07-08 |
|
||||
| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 |
|
||||
| [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 |
|
||||
| [Developer-owned SDK projects](proposed/feature/2026-07-14-sdk-developer-projects.md) | 2026-07-14 |
|
||||
|
||||
### Simplification
|
||||
|
||||
@@ -27,6 +28,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| Title | First proposed |
|
||||
|---|---|
|
||||
| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
|
||||
| [SDK project editing architecture](proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) | 2026-07-15 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-15-sdk-project-editing-architecture.md: 985cc22c159c68801b78262aa96c7422bdfa1318
|
||||
2026-07-15-sdk-project-editing-architecture.zh.md: 6a194e8e5f193e62bfc283fd93a5fde0367fe196
|
||||
@@ -0,0 +1,129 @@
|
||||
# RFC: SDK project editing architecture
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-15-sdk-project-editing-architecture.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Developer-owned SDK projects](../feature/2026-07-14-sdk-developer-projects.md) are created through create, adjusted through config, and built and run through commands such as start. Initial creation, configuration changes, and build and runtime commands all need to understand features, feature options, npm dependencies, Cordis config entries, environment variables, package managers, local plugins, and several project files. If each project-reading and project-writing workflow uses a separate interpretation protocol, the SDK developer workflows become difficult to maintain.
|
||||
|
||||
## Proposal
|
||||
|
||||
The SDK uses one shared object-oriented project model. `SdkProject` is a read-only snapshot, and `ProjectEditSession` is the only mutation and commit boundary. Feature objects own their feature options, relationships, resource contributions, and current-state inspection. Create and config orchestrate only their respective user workflows and modify projects through the same domain operations.
|
||||
|
||||
Structured files are modified through document objects, while one-shot text artifacts are generated from complete templates. Questions are typed objects presented through clack. Diff calculation may remain an edit-session implementation detail, but it is not a public execution protocol that callers must assemble.
|
||||
|
||||
## Terminology
|
||||
|
||||
| Term | Usage in this RFC | Meaning |
|
||||
|---|---|---|
|
||||
| Feature | feature | A product unit curated and managed by the SDK; one feature may contain several feature options and contribute several Cordis config entries, npm dependencies, environment placeholders, and owned files |
|
||||
| Feature option | feature option | A finite selectable implementation or configuration shape within one feature; feature rules may make options fixed, exclusive, or additive |
|
||||
| Cordis plugin | Cordis plugin | A plugin implementation loaded by Cordis, usually exported by an npm package; it is not an item in `cordis.yml` |
|
||||
| Cordis config entry | Cordis config entry | One item in the `cordis.yml` plugin list, identified as an instance by `id` and referring to a Cordis plugin through `name` |
|
||||
| Cordis plugin config | Cordis plugin config | The configuration object or shape exposed by a Cordis plugin; an individual field owned and updated by a feature is a config key |
|
||||
| config key | config key | One field in Cordis plugin config; a feature updates only the config keys it declares as owned and preserves unknown config keys |
|
||||
| npm dependency | npm dependency | A package relationship in `package.json`; literal fields such as `dependencies` and `devDependencies` keep their names |
|
||||
| Feature requirement | feature requirement | A relationship declared through `requires` by a feature or feature option |
|
||||
|
||||
## Package boundaries
|
||||
|
||||
| Package | Responsibility | Does not own |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-helper` | Edit sessions, feature configuration, project-template rendering, package-manager adaptation, and prompt interaction adaptation | Booting Cordis applications or deciding create/config terminal workflows |
|
||||
| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`, process lifecycle, project entry loading, the config workflow, and its terminal-copy templates | Interpreting feature definitions directly or modifying YAML/JSON ASTs |
|
||||
| `@deepseek-ai/create-sdk` | Arguments, question order, initial project creation, installation finish, and terminal-copy templates for `npm create @deepseek-ai/sdk` | Becoming a generated project's runtime npm dependency or providing a library API |
|
||||
|
||||
`@deepseek-ai/create-sdk` is the only exception to the repository's `@deepseek-ai/dsh-*` naming rule. npm's scoped-initializer convention requires that package name for `npm create @deepseek-ai/sdk`. The exception is a repository architecture fact and does not add a third developer product entrypoint.
|
||||
|
||||
The three packages export only the narrow entrypoints consumed by adjacent layers and provide no `src/*` deep imports. The scripts library entrypoint and build-config subpath serve generated code and project build configuration, while the developer product contract remains the `dsh-sdk` commands.
|
||||
|
||||
## Project aggregate and edit session
|
||||
|
||||
`SdkProject.create(root, request)` constructs a new project snapshot that has not been written, while `SdkProject.open(root)` loads an existing project. Open requires only readable root `package.json` and `cordis.yml` files; every other file is an optional resource. Both paths return the same read-only aggregate and distinguish their source through explicit origin state.
|
||||
|
||||
`project.edit()` clones project documents into a working copy. Domain commands such as install, configure, enable, disable, and addPlugin modify only the working copy. Each command immediately re-inspects its owning feature, and the final commit checks all relationships and files again.
|
||||
|
||||
```text
|
||||
validate feature requirements and resource ownership
|
||||
-> validate every affected document
|
||||
-> compute changed and removed paths
|
||||
-> compare existing files with the session's original text
|
||||
-> write through one commit boundary
|
||||
-> return a new SdkProject snapshot and ChangeSet
|
||||
```
|
||||
|
||||
Validation failure or an external edit causes zero writes. “One commit” means only zero pre-write side effects and one write entrypoint. `ChangeSet` describes final feature, plugin, and file changes for Review & Apply and create completion.
|
||||
|
||||
## Features and resource ownership
|
||||
|
||||
A feature is a first-class behavior object. Shallow base classes implement install, configure, enable, disable, required/requires validation, and common state inspection. Features with fixed, exclusive, or additive feature options share these lifecycles. Only features whose resource contributions depend on project context or require custom round-tripping use dedicated behavior classes; other features declare their actual differences through standardized data.
|
||||
|
||||
Each feature contributes stable-keyed Cordis config entries, npm dependencies, environment placeholders, and owned files. The registry rejects two features that declare the same resource key during initialization. Different feature options within one feature may share resources, which that feature resolves from the final option set.
|
||||
|
||||
A Cordis config entry anchors feature installation. The npm package name assigns the entry to a feature, and the entry ID distinguishes several instances of one plugin package. An npm dependency without a feature-owned Cordis config entry leaves the feature uninstalled. Once a Cordis config entry exists, a missing npm dependency, unreadable Cordis plugin config, or resource conflict puts the feature into an inconsistent state; the config command shows diagnostics and refuses speculative modification.
|
||||
|
||||
Configuring the same feature option updates only its owned config keys and preserves unknown keys. Replacing a feature option removes old resources that are exclusive and still confirmable. If an old resource cannot be confirmed or an owned file was modified by the developer, the whole operation fails.
|
||||
|
||||
## Questions and workflows
|
||||
|
||||
TypeScript `Question<T>` objects keep defaults, validation, applicability, and types together. `PromptPort` is the only interface between the domain layer and the terminal library, and helper provides one thin `ClackPromptPort`. Create and config inject their own command-line input and output streams and retain ownership of cancellation, return, and completion semantics in their workflows.
|
||||
|
||||
Create keeps its stateful question order in one wizard, while config keeps final-state selection in one workflow. Both use the same feature configurator for feature options and dedicated inputs, so adding an ordinary feature, feature option, or parameter does not require changes to both entrypoints.
|
||||
|
||||
## Project documents and templates
|
||||
|
||||
Only structured files that helper reads or modifies have concrete document objects: `package.json`, `cordis.yml`, `.env`, `.env.example`, the root `tsconfig.json`, and the pnpm workspace file. Document objects own parsing, cloning, validation, and serialization. Concrete classes and modules use `*File` and `*-file.ts` names respectively. Business code does not manipulate YAML/JSON ASTs directly, and malformed shapes fail loudly at the owning document boundary.
|
||||
|
||||
README, entrypoint code, build configuration, `.gitignore`, and other one-shot text artifacts use one complete template per real file. Complete product copy such as CLI usage, creation and recovery messages, installation and retry guidance, and the default persona also comes from package-local templates owned by the package that presents it.
|
||||
|
||||
Helper provides the generic typed `TextTemplate` renderer, and caller packages load their own templates through package-local asset URLs.
|
||||
|
||||
Templates use Handlebars strict mode and `noEscape` without custom processing. File owners encode typed values for the target language. Template source escapes interpolation as `\{{model}}` when it must emit the downstream literal unchanged.
|
||||
|
||||
## Command and runtime boundary
|
||||
|
||||
Scripts supports `dsh-sdk start/dev/build/config`. Start dynamically loads a module target and calls its named entrypoint. Dev adds TypeScript and local-workspace source resolution before following the same path. Build invokes the project's installed tsdown. Config opens one edit session and commits after Review & Apply. Generated projects run `tsc -b` directly for typechecking.
|
||||
|
||||
HMR is an explicit Cordis config entry loaded by dev and start. Its required `node-addon-require-builtin` package is supplied transitively by the scripts package and is absent from the generated project's `package.json`.
|
||||
|
||||
Dev and start execute the developer entrypoint, where developer code handles command-line arguments and cwd. Developers pass `--model=<name>` and `--resume=<session-id>` to start the standard flow.
|
||||
|
||||
## Repository live-link mode
|
||||
|
||||
Create-sdk retains a hidden `--link-workspace` option for Harness repository development and e2e. The parser accepts it, but help, public flag lists, and ordinary user documentation omit it. It accepts no repository-path parameter; the repository root is derived upward from the executing create-sdk module.
|
||||
|
||||
Link mode preserves the ordinary project file shape. `@deepseek-ai/*` points into `packages/`, Cordis-related npm dependencies point into `vendor/`, and shared lower-level packages resolve to the same physical copy used by the repository so Cordis type merging cannot produce multiple module type definitions. npm uses `file:`, pnpm uses `link:` with automatic peer installation disabled, and Yarn uses `portal:` plus resolutions. Repository packages must be built first.
|
||||
|
||||
## Future work
|
||||
|
||||
- **Replaceable required spine roles.** The current `spine` owns the full implementation set, including SystemPrompt and LLMService, through one fixed feature option. Developers cannot replace or switch these roles and must edit Cordis config entries manually.
|
||||
- **Service contracts and package declarations.** When replacing a builtin service, a Cordis plugin currently cannot declare the services it provides through `provides` metadata, so the SDK cannot assist configuration during development or check compatibility at runtime. A corresponding protocol remains to be designed.
|
||||
- **Feature parameter descriptions.** Feature-specific inputs currently require handwritten declarations. The SDK cannot derive interactive parameters automatically from arbitrary Cordis plugin config or npm package.json information. Future declarative metadata may expose a limited parameter set without turning arbitrary Cordis plugin config into a generic form.
|
||||
- **SDK application-level configuration.** The current project resource model describes Cordis config entries and config keys owned by individual Cordis plugins, so every SDK-managed setting must belong to one plugin. Cross-plugin or whole-application settings have no independent persistence location. Future work must define an application-level configuration document and its ownership, read, and mutation boundaries.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the static Catalog and central engine.** This minimizes the initial rewrite, but feature parameters, round-tripping, owned files, and create/config reuse continue to accumulate in one coordinator. Splitting files shortens the file without consolidating responsibility.
|
||||
|
||||
**Use `wizard.json` and a generic Questionnaire.** Static forms cannot directly express feature requirements, option switches, existing-value refill, and project-resource changes. Types, gates, and dynamic options still connect through string registries and a procedural `run()`, creating another internal DSL.
|
||||
|
||||
**Expose the live-link flag.** The mode depends on Harness monorepo layout and unpublished packages and serves repository development only. Making it public would create a project-creation contract that the SDK cannot support outside the repository.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Create and config modify projects only through `SdkProject` and `ProjectEditSession`; any business, document, or concurrency validation failure before writing leaves the filesystem unchanged
|
||||
- Adding an ordinary feature, feature option, or parameter extends only its typed spec or owning behavior object, without adding a central switch to create or config workflows
|
||||
- Helper owns the feature model, npm dependency and other resource configuration, and inconsistent-state detection
|
||||
- Structured files change through `*File` document objects; one-shot files and complete product copy come from package-owned Handlebars templates, and business decisions do not enter a template DSL
|
||||
- `dsh-sdk start/dev/build/config` is the runtime product surface, typecheck uses `tsc -b` directly, HMR is not injected by command mode, and only the scripts package transitively supplies `node-addon-require-builtin`
|
||||
- `--link-workspace` exists only as a hidden repository-development option and preserves one module identity under npm, pnpm, and Yarn
|
||||
|
||||
## Risks
|
||||
|
||||
- Behavior objects and typed specs create two extension shapes. Dedicated classes must remain limited to features that truly depend on project context or custom behavior, or the design will grow a meaningless type hierarchy
|
||||
- Optimistic concurrency checks and pre-write validation cannot recover from an I/O failure during writing; callers must still report a possible partial commit to the developer
|
||||
- Hidden link mode depends on repository layout and package-manager link semantics and must change with either one
|
||||
- The Cordis loader resolves `node-addon-require-builtin` from its own module path, so the scripts package must continue to satisfy that optional peer under npm, pnpm, and Yarn npm dependency layouts
|
||||
- Handlebars `noEscape` makes typed model construction responsible for target-language encoding; new template fields must be escaped correctly at the owning boundary, and downstream Handlebars placeholders must be escaped explicitly in template source
|
||||
@@ -0,0 +1,129 @@
|
||||
# RFC: SDK 工程编辑架构
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-15-sdk-project-editing-architecture.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[开发者拥有的 SDK 工程](../feature/2026-07-14-sdk-developer-projects.md) 由 create 创建,可以通过 config 调整,并由 start 等命令构建和运行。初始创建、配置调整和编译运行都需要理解功能、功能选项、NPM 依赖、Cordis 配置项、环境变量、包管理器、本地插件和多个项目文件。如果读写项目的各个流程分别使用不同的解析协议,SDK 开发者流程会变得难以维护。
|
||||
|
||||
## 提案
|
||||
|
||||
SDK 使用一个共享的面向对象工程模型。`SdkProject` 是只读快照,`ProjectEditSession` 是唯一修改与提交边界;功能对象负责自身的功能选项、关系、资源贡献和现状识别;create 与 config 只编排各自的用户流程,并通过同一组领域操作修改工程。
|
||||
|
||||
结构化文件通过文档对象修改,一次性文本产物通过完整模板生成。问题由类型化对象表达,并使用 clack 交互。差异计算可以作为编辑会话的内部实现,但不成为要求调用方组装的公共执行协议。
|
||||
|
||||
## 术语
|
||||
|
||||
| 名词 | 本文用词 | 含义 |
|
||||
|---|---|---|
|
||||
| Feature | 功能 | SDK 人工策划和管理的产品单元;一项功能可以包含多个功能选项,并贡献多个 Cordis 配置项、NPM 依赖、环境变量占位和独占文件 |
|
||||
| Feature option | 功能选项 | 一项功能内有限、可选择的实现或配置形状;根据功能规则可以固定、互斥或多选 |
|
||||
| Cordis plugin | Cordis 插件 | Cordis 加载的插件实现,通常由一个 NPM 包导出;它不是 `cordis.yml` 中的一项配置 |
|
||||
| Cordis config entry | Cordis 配置项 | `cordis.yml` 插件列表中的一项,通过 `id` 标识实例并通过 `name` 指向 Cordis 插件 |
|
||||
| Cordis plugin config | Cordis 插件配置 | Cordis 插件公开的配置对象或配置结构;其中由功能拥有并更新的单个字段称为“配置键” |
|
||||
| config key | 配置键 | Cordis 插件配置中的单个字段;功能只更新自己声明拥有的配置键,并保留未知配置键 |
|
||||
| npm dependency | NPM 依赖 | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |
|
||||
| Feature requirement | 功能依赖 | 功能或功能选项通过 `requires` 声明的关系 |
|
||||
|
||||
## Package 边界
|
||||
|
||||
| Package | 责任 | 不负责 |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-helper` | 编辑会话、功能配置、工程模板渲染、包管理适配和 prompt 交互适配 | 启动 Cordis 应用或决定 create/config 的终端流程 |
|
||||
| `@deepseek-ai/dsh-scripts` | `dsh-sdk start/dev/build/config`、进程生命周期、项目入口加载、config 流程和所属终端文案模板 | 直接解释功能定义或修改 YAML/JSON AST |
|
||||
| `@deepseek-ai/create-sdk` | `npm create @deepseek-ai/sdk` 的参数、问题顺序、首次工程创建、安装收尾和所属终端文案模板 | 成为生成工程的运行时 NPM 依赖或提供库 API |
|
||||
|
||||
`@deepseek-ai/create-sdk` 是仓库 `@deepseek-ai/dsh-*` 命名规则的唯一例外;npm scoped initializer 约定要求 `npm create @deepseek-ai/sdk` 对应这个 package 名。该例外是仓库架构事实,不增加第三个开发者产品入口。
|
||||
|
||||
三个 package 只导出相邻层实际使用的最小入口,不提供 `src/*` 深路径。scripts 的库入口与构建配置子路径服务生成代码和项目构建配置,但开发者产品合同仍由 `dsh-sdk` 命令承担。
|
||||
|
||||
## 工程聚合与编辑会话
|
||||
|
||||
`SdkProject.create(root, request)` 构造尚未写盘的新工程快照,`SdkProject.open(root)` 加载已有工程。open 只要求根 `package.json` 与 `cordis.yml` 可读,其余文件是按需存在的资源;两条路径返回同一种只读聚合,并通过显式 origin 区分来源。
|
||||
|
||||
`project.edit()` 克隆项目文档形成 working copy。install、configure、enable、disable 和 addPlugin 等领域命令只修改 working copy;命令完成后立即重新检查所属功能,最终 commit 再检查全部关系和文件。
|
||||
|
||||
```text
|
||||
validate feature requirements and resource ownership
|
||||
-> validate every affected document
|
||||
-> compute changed and removed paths
|
||||
-> compare existing files with the session's original text
|
||||
-> write through one commit boundary
|
||||
-> return a new SdkProject snapshot and ChangeSet
|
||||
```
|
||||
|
||||
校验失败或检测到会话外修改时不写盘。“一次 commit”只表示写入前零副作用和单一写入口。`ChangeSet` 只描述功能、插件和文件的最终变化,用于 Review & Apply 与 create 收尾。
|
||||
|
||||
## 功能与资源所有权
|
||||
|
||||
功能是一等行为对象。浅层基类实现 install、configure、enable、disable、required/requires 校验和共同状态识别;固定功能选项、互斥功能选项与可多选功能选项共享这些生命周期。只有资源贡献依赖项目上下文或需要自定义 round-trip 的功能才使用专用行为类,其余功能通过标准化数据声明真正不同的部分。
|
||||
|
||||
每项功能贡献带稳定 key 的 Cordis 配置项、NPM 依赖、环境变量占位和独占文件。注册表初始化时拒绝不同功能声明同一个资源 key;同一功能的不同功能选项可以共享资源,并由该功能根据最终选项集合处理。
|
||||
|
||||
Cordis 配置项是功能安装锚点。NPM 包名判断配置项所属的功能,配置项 ID 区分同一插件包的多个实例;只有 NPM 依赖而没有功能拥有的 Cordis 配置项时,该功能仍视为未安装。Cordis 配置项存在后,缺失 NPM 依赖、无法读取的 Cordis 插件配置或资源冲突会使功能进入不一致状态,config 命令显示诊断并拒绝猜测式修改。
|
||||
|
||||
同一功能选项只更新其声明拥有的配置键,保留未知键。替换功能选项会删除旧功能选项独占且仍可确认的资源;无法确认旧资源或发现独占文件被用户修改时,整个操作失败。
|
||||
|
||||
## 问题与 workflow
|
||||
|
||||
问题由 TypeScript `Question<T>` 对象表达,默认值、校验、适用条件和类型留在同一个对象中。`PromptPort` 是领域层与终端库之间的唯一接口,helper 提供一份薄 `ClackPromptPort`;create 和 config 注入各自的命令行输入输出流,并在各自流程中决定取消、返回和收尾语义。
|
||||
|
||||
create 的有状态问题顺序留在一个向导中,config 的最终状态选择留在一个流程中。两者通过同一个功能配置器收集功能选项与专用输入,因此增加一项普通功能、功能选项或参数不要求同时修改两个入口。
|
||||
|
||||
## 项目文档与模板
|
||||
|
||||
只有需要读取或修改的结构化文件拥有具体文档对象,包括 `package.json`、`cordis.yml`、`.env`、`.env.example`、根 `tsconfig.json` 和 pnpm workspace 文件。文档对象拥有解析、克隆、校验和序列化行为;具体类与模块分别使用 `*File` 和 `*-file.ts` 命名,业务层不直接操作 YAML/JSON AST,异常形状在所属文档边界 fail loud。
|
||||
|
||||
README、入口代码、构建配置、`.gitignore` 和其他一次性文本产物使用与真实文件一一对应的完整模板。CLI usage、创建结果与恢复提示、安装与重试指导以及默认 persona 等完整产品文案也由所属 package 的本地模板提供。
|
||||
|
||||
helper 提供通用的数据类型化 `TextTemplate` 模板渲染器,调用 package 通过本地 asset URL 加载自己的模板。
|
||||
|
||||
模板使用 Handlebars strict mode 与 `noEscape`,不进行自定义处理。文件对象负责把类型化数据值编码成目标语言文本;如果不希望插值,则源码以 `\{{model}}` 等转义形式输出下游。
|
||||
|
||||
## 命令与运行边界
|
||||
|
||||
scripts 支持 `dsh-sdk start/dev/build/config`。start 动态加载模块 target 并调用其命名入口;dev 在同一路径前增加 TypeScript 与本地 workspace 源码解析;build 调用工程安装的 tsdown;config 打开一个编辑会话并在 Review & Apply 后提交。typecheck 由生成工程直接执行 `tsc -b`。
|
||||
|
||||
HMR 作为显式 Cordis 配置项由 dev 和 start 加载;它所需的 `node-addon-require-builtin` 由 scripts package 传递提供,不写入开发者工程的 `package.json`。
|
||||
|
||||
dev/start 会执行开发者入口,在开发者代码中处理命令行参数、cwd,由开发者自行传入 `--model=<name>` 与 `--resume=<session-id>` 启动标准流程。
|
||||
|
||||
## 仓库本地链接模式
|
||||
|
||||
create-sdk 保留隐藏的 `--link-workspace` 选项供 Harness 仓库开发和 e2e 使用。该选项可以被解析,但不出现在 help、公开 flag 清单或普通用户文档中,也不接收仓库路径参数;仓库根从正在执行的 create-sdk 模块位置向上确定。
|
||||
|
||||
链接模式保持普通工程的文件形状。`@deepseek-ai/*` 指向 `packages/`,Cordis 相关 NPM 依赖指向 `vendor/`,共享底层 package 锚定到仓库实际使用的同一物理拷贝,避免 Cordis 类型合并产生多个模块类型定义。npm 使用 `file:`,pnpm 使用 `link:` 并关闭自动 peer 安装,Yarn 使用 `portal:` 与 resolutions;仓库 package 需要先构建。
|
||||
|
||||
## 后续工作
|
||||
|
||||
- **可替换的 required 主干角色。** 当前 `spine` 以一个固定功能选项拥有整组实现,包含 SystemPrompt、LLMService 等。无法让开发者对其进行替换和切换,只能手工修改 Cordis 配置项。
|
||||
- **Service contract 与 package 声明。** 替换特定内建服务时,Cordis 插件目前无法通过 `provides` 元数据声明其提供的服务,因此 SDK 无法在开发阶段辅助配置,也无法在运行时检查兼容性。后续需要设计相应协议。
|
||||
- **功能参数描述。** 当前功能的专用输入必须手工声明;SDK 无法从任意 Cordis 插件配置或 NPM package.json 信息中自动推导可交互参数。后续可以定义有限的声明式参数元数据,但不把任意 Cordis 插件配置转换成通用表单。
|
||||
- **SDK 应用级配置。** 当前项目资源模型只描述 Cordis 配置项及单个 Cordis 插件拥有的配置键,因此所有受 SDK 管理的配置都必须归属某个插件。跨插件或面向整个 SDK 应用的设置没有独立持久化位置;后续需要定义应用级配置文档及其所有权、读取和修改边界。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**保留静态 Catalog 与中心 engine。** 该方案改动最小,但功能参数、round-trip、独占文件和 create/config 复用都会继续进入同一个协调中心;拆文件只能缩短单文件,不能收拢职责。
|
||||
|
||||
**使用 `wizard.json` 与通用 Questionnaire。** 静态表单无法直接表达功能依赖、选项切换、已有值回填和项目资源变化;类型、gate 和动态 option 最终仍要通过字符串 registry 与过程式 `run()` 连接,形成新的内部 DSL。
|
||||
|
||||
**公开本地链接 flag。** 该模式依赖 Harness monorepo 布局和未发布 package,只服务仓库开发;公开后会形成无法对外兑现的项目创建合同,因此保持隐藏。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- create 与 config 只通过 `SdkProject` 和 `ProjectEditSession` 修改工程,写入前的任何业务、文件或并发校验失败都不产生磁盘变化
|
||||
- 新增普通功能、功能选项或参数只扩展类型化 spec 或所属行为对象,create/config 流程不增加中央 switch
|
||||
- 功能模型、NPM 依赖与其他资源配置、不一致检测由 helper 统一实现
|
||||
- 结构化文件通过 `*File` 文档对象修改;一次性文件和完整产品文案通过所属 package 的 Handlebars 模板生成,业务决策不进入模板 DSL
|
||||
- `dsh-sdk start/dev/build/config` 是运行产品面,typecheck 直接使用 `tsc -b`,HMR 不通过命令隐式注入,`node-addon-require-builtin` 只由 scripts package 传递提供
|
||||
- `--link-workspace` 只作为隐藏的仓库开发选项存在,并对 npm、pnpm 和 Yarn 保持单一模块身份
|
||||
|
||||
## 风险
|
||||
|
||||
- 行为对象与类型化 spec 并存会形成两种扩展形状;专用类必须只用于确实依赖项目上下文或自定义的功能,否则会重新产生无意义的类型层次
|
||||
- 乐观并发检查与写前校验不能解决写入中途的 I/O 故障,调用方仍需向开发者报告可能的部分提交
|
||||
- 隐藏链接模式依赖仓库目录与 package manager 链接语义,仓库布局或工具行为变化时必须与实现一起更新
|
||||
- Cordis loader 从自身模块路径加载 `node-addon-require-builtin`;npm、pnpm 或 Yarn 的 NPM 依赖布局变化时,scripts package 必须继续满足该可选对等依赖(optional peer dependency)
|
||||
- Handlebars 的 `noEscape` 把目标语言编码责任交给 typed model 构造方;新增模板字段时必须在 owner 处完成正确转义,下游 Handlebars 占位符必须在模板源码中显式转义
|
||||
@@ -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
|
||||
2026-07-14-sdk-developer-projects.md: 0b5fe876f92153e1ccf5bd8fe383464f5087f4b1
|
||||
2026-07-14-sdk-developer-projects.zh.md: ec08f323acba1b9dc049182937fda34bfb50d4ee
|
||||
@@ -0,0 +1,167 @@
|
||||
# RFC: Developer-owned SDK projects
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-14-sdk-developer-projects.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
DeepSeek Harness composes features through Cordis plugins, but building a runnable project from an empty directory still requires a developer to understand npm dependencies, the `cordis.yml` plugin set, environment variables, TypeScript builds, local-plugin workspaces, and runtime entrypoints together. These manual steps constrain one another: omitting any one can produce a project that installs but cannot be developed, develops but cannot be built, or builds but cannot start.
|
||||
|
||||
A one-shot generator reduces only the initial creation cost. If the generated result is hidden inside a preset or an uneditable CLI, advanced developers cannot reshape the plugin tree, change Cordis plugin config, or add project-specific behavior. If a generated project immediately leaves tool management altogether, developers must again maintain consistency across all npm dependencies and Cordis plugin config themselves.
|
||||
|
||||
Initial creation and later configuration address the same builtin feature set. When those workflows maintain separate feature lists, feature options, and npm dependencies, new Cordis plugins, npm packages, and Cordis plugin config changes make them diverge. Projects also need an ordinary local-plugin development path that participates in development, build, and start flows.
|
||||
|
||||
## Proposal
|
||||
|
||||
The SDK creates an ordinary, explicit TypeScript/Cordis project owned by its developer. `cordis.yml` is the only runtime plugin tree; development and production read the same file. The generated `package.json`, `cordis.yml`, TypeScript entrypoint, build configuration, and `plugins/*` remain directly editable instead of being hidden behind a preset.
|
||||
|
||||
The only developer product entrypoints are `npm create @deepseek-ai/sdk` and the `dsh-sdk` commands. The initializer performs initial creation, `dsh-sdk config` manages SDK-recognized builtin features afterward, and `dsh-sdk dev`, `dsh-sdk build`, and `dsh-sdk start` own development, build, and startup; this phase provides no `dsh-sdk create`. Create and config consume one manually authored feature definition, so each feature has one source for its feature options, npm dependencies, Cordis config entries, related files, and inspection rules. The [SDK project editing architecture](../architecture/2026-07-15-sdk-project-editing-architecture.md) defines terms such as feature and feature option.
|
||||
|
||||
The SDK offers interaction for feature selection and finite feature options only; it does not turn arbitrary Cordis plugin config into a generic form. A feature collects the small number of dedicated inputs required by its feature options. All other Cordis plugin config remains in `cordis.yml`, with comments documenting common edits, for direct developer control.
|
||||
|
||||
## Developer workflow
|
||||
|
||||
Initial creation collects information in an order where earlier answers determine later questions: target directory and package identity, model provider and credentials, run interface, builtin features and feature options, an optional local plugin, package manager, and whether to install npm dependencies and build. Command-line arguments suppress questions they already answer. Create and config require an interactive TTY in this phase, and cancelling creation writes nothing to the target directory.
|
||||
|
||||
```sh
|
||||
npm create @deepseek-ai/sdk my-agent
|
||||
cd my-agent
|
||||
npm exec dsh-sdk dev index.ts
|
||||
npm exec dsh-sdk config
|
||||
npm exec dsh-sdk build
|
||||
npm exec dsh-sdk start index.js
|
||||
```
|
||||
|
||||
Create rejects every target path that already exists. After committing the project files, the CLI asks whether to install npm dependencies and build. An install or build failure preserves the generated project and prints commands that can retry the failed work.
|
||||
|
||||
Create also offers one `none / plugin / tool` choice. `plugin` creates a fixed `plugins/plugin` Cordis plugin, while `tool` creates a fixed `plugins/tool` model-facing tool; one project creation includes at most one local plugin. The operation updates the workspace, root npm dependency, TypeScript reference, build configuration, and `cordis.yml` together, and any pre-write validation failure leaves the project absent.
|
||||
|
||||
## Features supported during creation
|
||||
|
||||
The table is the developer-visible support set for this phase. A `required` feature is always present but may still offer finite feature options; a `default` feature is preselected in the feature tree; an `optional` feature is selected explicitly. The table describes the product support set, while the runtime registry remains the implementation source of truth.
|
||||
|
||||
| Feature | Create state | Feature options | Constraints and relationships |
|
||||
|---|---|---|---|
|
||||
| `provider` | required | `deepseek` (default) / `custom` | DeepSeek collects an API key; custom also collects a base URL, and a CLI option may override the model name |
|
||||
| `app` | required | `stdio` (default) / `acp` / `embed` | Selects the run interface |
|
||||
| `spine` | required | `default` | Timer, the LLM seam, session storage, system prompt, the tool registry, the agent registry, and the agent loop |
|
||||
| `bash` | required | `local` (default) / `sandbox` | The two feature options are exclusive and independent of the run interface, and both install the model-facing bash tool; sandbox installs the local sandbox provider and sandboxed bash backend |
|
||||
| `persistence` | required | `jsonl` (default) / `sqlite` | Every project selects exactly one persistence backend |
|
||||
| `hmr` | default | `default` | Loads `@cordisjs/plugin-hmr`; dev and start both enable it with the plugin defaults |
|
||||
| `fs` | default | `local` | Installs the local filesystem, policy, and model-facing tools; the process sandbox does not confine in-process fs tools |
|
||||
| `todo` | default | `default` | Provides the `todo_write` tool |
|
||||
| `skill` | default | `default` | Installs the skill registry, the local skill provider, and the model-facing skill tool |
|
||||
| `web` | optional | `deepseek` (default) / `exa` / `perplexity` / `fetch-only` | Search feature options are exclusive; Exa and Perplexity collect their API keys; timeout policy is recommended |
|
||||
| `subagent` | optional | `spawn` (default) / `fork`, multiple | This phase provides only in-process backends |
|
||||
| `workflow` | optional | `workerthread` | Requires the subagent `spawn` feature option |
|
||||
| `compact` | optional | `basic` | Uses SDK-provided context-compaction parameters |
|
||||
| `hooks` | optional | `claude` (default) / `codex`, multiple | Each feature option creates a separate editable configuration file |
|
||||
| `guard` | optional | `repeat-tool` | Provides repeated-tool-call reminders |
|
||||
| `timeout-policy` | optional | `default` | Applies a uniform policy to tools that declare timeout budgets |
|
||||
| `ask-user` | optional | `default` | Provides the `ask_user_question` tool; only `acp` and `stdio` can select it because those two feature options provide the injected user-interaction service |
|
||||
|
||||
Both `bash` feature options apply to ACP, stdio, and embed and are not selected by the run interface. The sandbox feature option writes no active config key and therefore keeps `dsh-bash-sandbox`'s `read-only` default. Generated `cordis.yml` includes a commented example that developers can change explicitly to `workspace-write`:
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
# Uncomment to allow writes under the project workspace.
|
||||
# config:
|
||||
# mode: workspace-write
|
||||
# workspaceRoot: !!js process.cwd()
|
||||
```
|
||||
|
||||
Feature contributions reference only single-plugin npm packages and never bundle packages such as `agent-spine-demo`, `stdio-demo`, or `acp-demo`. Plugins outside the table are not managed by create in this phase; advanced developers may still compose them by editing the ordinary project files directly.
|
||||
|
||||
## Generated project
|
||||
|
||||
With default answers, an npm project uses the DeepSeek provider, the stdio interface, local bash, JSONL persistence, and the preselected hmr, fs, todo, and skill features. Its initial tree is:
|
||||
|
||||
```text
|
||||
my-agent/
|
||||
├── .env
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── cordis.yml
|
||||
├── index.ts
|
||||
├── package.json
|
||||
├── tsconfig.base.json
|
||||
├── tsconfig.json
|
||||
└── tsdown.config.ts
|
||||
```
|
||||
|
||||
`.env.example` always exists, and the SDK keeps its placeholders aligned with the current feature set. A gitignored `.env` is also created when a secret is captured or the developer confirms an empty credential to fill later. The SDK only appends differently named variables that are not already present in `.env` and never updates or removes existing contents. Feature-option changes may remove obsolete `.env.example` placeholders, while old credentials remain in `.env` for the developer to manage. pnpm and Yarn projects add their required workspace files, but do not fork the runtime plugin tree or TypeScript entrypoint.
|
||||
|
||||
Generated `package.json` provides the following scripts. `dev`, `build`, `start`, and `config` invoke `dsh-sdk`, while `typecheck` invokes TypeScript directly:
|
||||
|
||||
| Script | Behavior |
|
||||
|---|---|
|
||||
| `dev` | Run `dsh-sdk dev index.ts`, registering development-time resolution for TypeScript and local workspace plugins |
|
||||
| `build` | Run `dsh-sdk build`, invoking the project's installed tsdown for the root entrypoint and `plugins/*` packages |
|
||||
| `typecheck` | Run `tsc -b` directly |
|
||||
| `start` | Run `dsh-sdk start index.js`, starting the built entrypoint without an implicit build |
|
||||
| `config` | Run `dsh-sdk config` to edit the current project's feature tree |
|
||||
|
||||
`dsh-sdk start` and `dsh-sdk dev` accept a module target and forward arguments after `--` unchanged to the project entrypoint. Generic argument parsing uses Node `parseArgs()` with zero schema: valued flags use `--key=value`, bare flags become `true`, and `--no-*` becomes `false`.
|
||||
|
||||
- Stdio projects pass the selected model through `--model=<name>` and create or resume an agent according to optional `--resume=<session-id>`;
|
||||
- ACP uses protocol `session/load`
|
||||
- Embed uses the model written into the generated code.
|
||||
|
||||
Each feature-owned Cordis config entry keeps its developer-editable Cordis plugin config and explanatory comments in `cordis.yml`. When `dsh-sdk config` changes other features, it preserves unknown fields, formatting on untouched nodes, and comments. HMR is an ordinary leaf config entry: when the feature is selected, dev and start load the same watcher, and the command does not change the plugin tree implicitly.
|
||||
|
||||
## Post-creation configuration
|
||||
|
||||
`dsh-sdk config` requires only readable root `package.json` and `cordis.yml` files in the current directory. It inspects standard features and their current feature options, expresses the final desired state through one feature tree, and shows feature changes and affected files before Review & Apply.
|
||||
|
||||
`dsh-sdk config` can install missing features, enable or disable installed features, and switch finite feature options. Required features cannot be removed. An npm dependency change runs the project package manager's install once after the file commit; installation failure does not roll back committed project files.
|
||||
|
||||
The SDK modifies only Cordis config entries, config keys, npm dependencies, `.env.example` placeholders, and owned files explicitly owned by a feature. Updating the same feature option preserves unknown config keys in its Cordis config entries. Handwritten and third-party plugins support enable and disable by stable ID only. When a known feature has been edited into an incomplete, ambiguous, or otherwise unreadable shape, `dsh-sdk config` displays diagnostics and refuses automatic changes until the developer repairs it manually.
|
||||
|
||||
One config session accumulates every change in an in-memory working copy. Before Apply, it validates feature relationships, resource conflicts, and document shapes, then compares each affected existing file with the text read when the session opened. Validation failure or an external edit causes zero writes. Once physical writes begin, the SDK does not provide cross-file transactional rollback.
|
||||
|
||||
## Maintenance model
|
||||
|
||||
The SDK curates its builtin support set instead of exposing npm packages automatically by npm dependency name or directory convention. One feature may compose several Cordis config entries, feature options may share resources, and a feature option may declare a feature requirement on another feature or a specific feature option. Adding an ordinary feature or feature option does not require changes to both create and config command workflows.
|
||||
|
||||
## Future work
|
||||
|
||||
- `dsh-sdk add [package-spec]` unifies local-plugin creation with external Cordis plugin installation: without a package or repository source it creates a local plugin/tool, while a supplied source adds the npm dependency and `cordis.yml` config entry; the source model leaves room for GitHub repositories and other extensions
|
||||
- Non-interactive create/config: both workflows require a TTY in this phase and provide no complete input contract for automation
|
||||
- More feature-specific inputs: this product surface exposes only finite feature options, secrets, and a few dedicated values in this phase rather than a generic parameter interface for Cordis plugin config
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**An opaque preset or generator-owned project.** This shortens initial creation but hides the real plugin tree and build boundaries, prevents advanced developers from composing Cordis plugins directly, and makes project behavior depend on the CLI version rather than committed project files.
|
||||
|
||||
**A one-shot generator only.** Leaving all later maintenance manual redistributes feature requirements, feature-option switches, and multi-file updates. A config workflow over the shared registry retains continuing management for generated projects.
|
||||
|
||||
**Separate `cordis.yml` files for development and production.** Two plugin trees mean a successful development run does not demonstrate that production loads the same features. Dev adds only TypeScript and local-workspace resolution; runtime configuration remains singular.
|
||||
|
||||
**A generic form for arbitrary Cordis plugin config.** Cordis plugin config contains nested structures, expressions, and plugin-specific semantics. A generic form would become a second incomplete schema. The SDK manages finite feature options and dedicated secrets, while developers continue to edit complex config directly.
|
||||
|
||||
**A private local-plugin discovery protocol.** Ordinary package-manager workspaces, root npm dependencies, TypeScript references, and Cordis config entries already express the complete relationship. Another discovery protocol would create hidden state understood only by the SDK.
|
||||
|
||||
**A `dsh-sdk create` command for existing projects.** Create already provides one editable local-plugin skeleton, and later plugins can use ordinary workspace and Cordis mechanisms manually. A parallel command would add a second scaffolding product surface without adding composition functionality.
|
||||
|
||||
**Automatically expose every new Cordis plugin as a builtin.** An npm package cannot say how several plugins compose into one product feature, nor can it derive exclusivity, feature requirements, secrets, interface applicability, or security constraints. The support set requires human curation; automation is suitable only for checking whether candidates have been classified.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `npm create @deepseek-ai/sdk` collects project identity, provider, interface, features, an optional local plugin, package manager, and installation choice in the documented order, and cancellation leaves the target path absent
|
||||
- A default npm project has the documented tree and `dev`, `build`, `typecheck`, `start`, and `config` scripts, with dev and start sharing one `cordis.yml`
|
||||
- Create offers the documented features and feature options; local and sandbox bash are exclusive with local as the default, the sandbox Cordis config entry retains the editable commented config example, and HMR is selected by default and loaded by both dev and start
|
||||
- Create's `plugin` or `tool` choice creates at most one fixed-name local plugin and atomically updates its files and root-project relationships; this phase provides no `dsh-sdk create`
|
||||
- `dsh-sdk config` reads the same support set from an existing project, installs, enables, disables, and switches supported feature options, preserves unknown config and comments, and refuses to modify inconsistent config
|
||||
- `.env.example` reflects variables required by the current features; `.env` only appends missing differently named variables and never updates or removes existing contents
|
||||
- npm, pnpm, and Yarn workspaces install, build, and start; local plugins resolve from source under dev and from built output under start
|
||||
|
||||
## Risks
|
||||
|
||||
- Developers can edit a builtin into a shape the registry cannot recognize; the SDK stops automating that feature instead of guessing and overwriting config
|
||||
- Pre-write validation and external-edit detection do not provide transactional rollback once multi-file writes begin; an I/O failure can leave a partial commit requiring manual repair
|
||||
- The sandbox feature option depends on an available local sandbox backend for the target platform; an unavailable backend must fail closed instead of falling back to unsandboxed execution
|
||||
- HMR retains its filesystem watcher and hot-reload behavior under production start; this is the result of an explicit plugin choice, not an implicit development-only service
|
||||
- The append-only `.env` policy retains credentials that are no longer used; the SDK does not decide when user-owned secret data is safe to delete
|
||||
@@ -0,0 +1,167 @@
|
||||
# RFC: 开发者拥有的 SDK 工程
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-14-sdk-developer-projects.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
DeepSeek Harness 通过 Cordis 插件对功能进行组合,但从空目录开始搭建一个可运行工程仍要求开发者同时理解 NPM 依赖、`cordis.yml` 插件组、环境变量、TypeScript 构建、本地插件 workspace 和运行入口。手工步骤之间存在约束,漏掉任意一处都会得到能够安装却无法开发、能够开发却无法构建,或能够构建却无法启动的工程。
|
||||
|
||||
一次性生成器只能降低首次创建成本。若生成结果隐藏在 preset 或不可编辑的 CLI(命令行界面)内部,高级开发者无法调整插件树、修改 Cordis 插件配置或增加项目特有行为;若创建后的工程完全脱离工具管理,开发者又必须重新承担所有 NPM 依赖和 Cordis 插件配置的一致性工作。
|
||||
|
||||
初始创建和后续配置面对同一组内置功能。两条流程各自维护功能列表、功能选项和 NPM 依赖时,新增 Cordis 插件、NPM 包或调整配置会使二者逐渐分叉。工程还需要一条普通的本地插件开发路径,参与开发、构建和启动流程。
|
||||
|
||||
## 提案
|
||||
|
||||
SDK 创建一个普通、显式且归开发者所有的 TypeScript/Cordis 工程。`cordis.yml` 是唯一的运行时插件树;开发和生产读取同一份文件。工程中的 `package.json`、`cordis.yml`、TypeScript 入口、构建配置和 `plugins/*` 均可直接编辑,SDK 不把它们封装成不可见的 preset。
|
||||
|
||||
开发者产品入口只有 `npm create @deepseek-ai/sdk` 和 `dsh-sdk` 命令。前者负责首次创建,`dsh-sdk config` 在创建后管理 SDK 能识别的内置功能,`dsh-sdk dev`、`dsh-sdk build` 与 `dsh-sdk start` 负责开发、构建和启动;本期不提供 `dsh-sdk create`。create 与 config 使用同一份人工编写的功能定义,因此一项功能的功能选项、NPM 依赖、Cordis 配置项、相关文件和识别规则只有一个来源。功能、功能选项等名词由 [SDK 工程编辑架构](../architecture/2026-07-15-sdk-project-editing-architecture.md) 的术语表定义。
|
||||
|
||||
SDK 只为功能选择和有限功能选项提供交互,不尝试把任意 Cordis 插件配置变成通用表单。功能选项所需的少量专用输入由所属功能收集;其余 Cordis 插件配置留在 `cordis.yml` 中,并通过注释指明常用改法,由开发者直接修改。
|
||||
|
||||
## 开发者流程
|
||||
|
||||
首次创建按会影响后续问题集合的顺序收集信息:目标目录与 package 身份、模型提供方与凭据、运行接口、内置功能与功能选项、可选本地插件、包管理器,以及是否安装 NPM 依赖并构建。命令参数已提供的答案不重复询问;本期 create 和 config 都要求交互式 TTY,取消创建时不写入目标目录。
|
||||
|
||||
```sh
|
||||
npm create @deepseek-ai/sdk my-agent
|
||||
cd my-agent
|
||||
npm exec dsh-sdk dev index.ts
|
||||
npm exec dsh-sdk config
|
||||
npm exec dsh-sdk build
|
||||
npm exec dsh-sdk start index.js
|
||||
```
|
||||
|
||||
create 拒绝任何已经存在的目标路径。工程文件提交成功后,CLI 询问是否安装 NPM 依赖并构建;安装或构建失败时保留生成结果,并打印可以重新执行的命令。
|
||||
|
||||
create 还提供一次 `none / plugin / tool` 选择。`plugin` 固定生成 `plugins/plugin` 的 Cordis 插件,`tool` 固定生成 `plugins/tool` 的模型工具;一次创建至多包含一个本地插件。生成操作同时更新 workspace、根 NPM 依赖、TypeScript reference、构建配置和 `cordis.yml`,任何写入前校验失败都不创建工程。
|
||||
|
||||
## 创建时支持的功能
|
||||
|
||||
下表是本期 create 面向开发者展示的支持集。`required` 始终存在但仍可切换有限功能选项;`default` 在选择树中预选;`optional` 由开发者主动选择。表格说明产品支持集,运行时注册表是实现的事实源。
|
||||
|
||||
| 功能 | create 状态 | 功能选项 | 限制与关系 |
|
||||
|---|---|---|---|
|
||||
| `provider` | required | `deepseek`(默认)/ `custom` | DeepSeek 收集 API key;custom 另收集 base URL,模型名可由 CLI 参数覆盖 |
|
||||
| `app` | required | `stdio`(默认)/ `acp` / `embed` | 选择运行接口 |
|
||||
| `spine` | required | `default` | timer、LLM seam、会话存储、系统提示词、工具注册表、agent 注册表,以及 agent loop |
|
||||
| `bash` | required | `local`(默认)/ `sandbox` | 两个功能选项互斥、与运行接口正交,且都安装面向模型的 bash 工具;sandbox 安装本地沙箱提供方和沙箱 bash 后端 |
|
||||
| `persistence` | required | `jsonl`(默认)/ `sqlite` | 每个工程恰好选择一个持久化后端 |
|
||||
| `hmr` | default | `default` | 加载 `@cordisjs/plugin-hmr`;dev 和 start 都启用,使用插件默认配置 |
|
||||
| `fs` | default | `local` | 安装本地文件系统、策略和模型工具;进程沙箱不约束进程内 fs 工具 |
|
||||
| `todo` | default | `default` | 提供 `todo_write` 工具 |
|
||||
| `skill` | default | `default` | 安装 skill(技能)注册表、本地 skill 提供方和面向模型的 skill 工具 |
|
||||
| `web` | optional | `deepseek`(默认)/ `exa` / `perplexity` / `fetch-only` | 搜索功能选项互斥;Exa/Perplexity 收集各自 API key;建议同时启用 timeout policy |
|
||||
| `subagent` | optional | `spawn`(默认)/ `fork`,可多选 | 本期只提供进程内后端 |
|
||||
| `workflow` | optional | `workerthread` | 要求 subagent 的 `spawn` 功能选项 |
|
||||
| `compact` | optional | `basic` | 使用 SDK 提供的上下文压缩参数 |
|
||||
| `hooks` | optional | `claude`(默认)/ `codex`,可多选 | 各功能选项生成独立的可编辑配置文件 |
|
||||
| `guard` | optional | `repeat-tool` | 提供重复工具调用提醒 |
|
||||
| `timeout-policy` | optional | `default` | 对声明超时预算的工具执行统一策略 |
|
||||
| `ask-user` | optional | `default` | 提供 `ask_user_question` 工具;注入的 user-interaction 服务由 acp/stdio 两个功能选项提供,因此仅这两个接口可选 |
|
||||
|
||||
`bash` 的两个功能选项都适用于 ACP、stdio 和 embed,不由运行接口决定。sandbox 功能选项不写任何生效的配置键,因而沿用 `dsh-bash-sandbox` 的 `read-only` 默认值;生成的 `cordis.yml` 保留注释示例,开发者可以显式改为 `workspace-write`:
|
||||
|
||||
```yaml
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
# Uncomment to allow writes under the project workspace.
|
||||
# config:
|
||||
# mode: workspace-write
|
||||
# workspaceRoot: !!js process.cwd()
|
||||
```
|
||||
|
||||
功能贡献只引用单插件 NPM 包,绝不引用 `agent-spine-demo`、`stdio-demo`、`acp-demo` 这类组合 NPM 包。表格之外的插件不由本期 create 管理;开发者仍可直接编辑普通工程文件进行高级组合。
|
||||
|
||||
## 生成工程
|
||||
|
||||
使用默认答案创建 npm 工程时,provider 为 DeepSeek,运行接口为 stdio,bash 为 local,持久化为 JSONL,hmr、fs、todo 与 skill 处于选中状态。初始目录树为:
|
||||
|
||||
```text
|
||||
my-agent/
|
||||
├── .env
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── cordis.yml
|
||||
├── index.ts
|
||||
├── package.json
|
||||
├── tsconfig.base.json
|
||||
├── tsconfig.json
|
||||
└── tsdown.config.ts
|
||||
```
|
||||
|
||||
`.env.example` 始终存在,并由 SDK 根据当前功能维护占位。收集到 secret 或开发者确认稍后填写空凭据时,同时生成 gitignored `.env`。SDK 只向 `.env` 追加尚不存在的不同名变量,绝不覆盖或删除已有内容;切换功能选项可以清理 `.env.example` 中不再需要的占位,但旧凭据仍留在 `.env` 中供开发者自行处理。pnpm 和 Yarn 工程增加各自所需的 workspace 配置文件,但运行时插件树和 TypeScript 入口不分叉。
|
||||
|
||||
生成的 `package.json` 提供以下 scripts;其中 `dev`、`build`、`start` 与 `config` 调用 `dsh-sdk`,`typecheck` 直接调用 TypeScript:
|
||||
|
||||
| script | 行为 |
|
||||
|---|---|
|
||||
| `dev` | 运行 `dsh-sdk dev index.ts`,为 TypeScript 和本地 workspace 插件注册开发期解析 |
|
||||
| `build` | 运行 `dsh-sdk build`,调用工程安装的 tsdown 构建根入口和 `plugins/*` package |
|
||||
| `typecheck` | 直接运行 `tsc -b` |
|
||||
| `start` | 运行 `dsh-sdk start index.js`,启动已构建入口且不隐式构建 |
|
||||
| `config` | 运行 `dsh-sdk config`,修改当前工程功能树 |
|
||||
|
||||
`dsh-sdk start` 与 `dsh-sdk dev` 可以接收模块 target,并把 `--` 后的参数原样转发给工程入口。通用参数解析使用 Node `parseArgs()` 的零 schema 模式:带值 flag 采用 `--key=value`,bare flag 转换为 `true`,`--no-*` 转换为 `false`。
|
||||
|
||||
- stdio 工程通过 `--model=<name>` 传入所选 model,并根据可选的 `--resume=<session-id>` 创建或恢复 agent;
|
||||
- acp 使用协议 `session/load`
|
||||
- embed 使用生成代码中的 model。
|
||||
|
||||
每个功能拥有的 Cordis 配置项在 `cordis.yml` 中保留自己的可编辑 Cordis 插件配置和说明注释;`dsh-sdk config` 修改其他功能时必须保留未知字段、未修改节点的格式和注释。HMR(热模块替换)是普通叶子配置项:选择该功能后,dev 和 start 加载同一个 watcher,命令不隐式改变插件树。
|
||||
|
||||
## 创建后的配置
|
||||
|
||||
`dsh-sdk config` 只要求当前目录具有可读的根 `package.json` 与 `cordis.yml`。它检查标准功能及其当前功能选项,以一棵功能树表达最终目标状态,并在 Review & Apply 前展示功能变化和受影响文件。
|
||||
|
||||
`dsh-sdk config` 可以安装缺失功能、启停已安装功能和切换有限功能选项。required 功能不能取消。改变 NPM 依赖后只运行一次项目包管理器安装;安装失败不回滚已经提交的工程文件。
|
||||
|
||||
SDK 只修改功能明确拥有的 Cordis 配置项、配置键、NPM 依赖、`.env.example` 占位和独占文件。同一功能选项的更新保留 Cordis 配置项中的未知配置键;手写或第三方插件只支持按稳定 ID 启停。已知功能被手改成不完整、歧义或无法读取的形状时,`dsh-sdk config` 显示诊断并拒绝自动修改,直到开发者手工修复。
|
||||
|
||||
一次 config 会话在内存工作区上累计全部修改。Apply 前完成功能关系、资源冲突和文件形状校验,并比较受影响文件与会话打开时的原文;校验失败或检测到外部修改时不写盘。实际写盘开始后不提供跨文件事务回滚。
|
||||
|
||||
## 维护模型
|
||||
|
||||
Builtin 支持集由 SDK 人工策划,不根据 NPM 依赖名称或目录约定自动暴露。一个功能可以组合多个 Cordis 配置项,功能选项可以共享资源,并声明对其他功能或特定功能选项的功能依赖;新增普通功能或功能选项不应要求同时修改 create 和 config 两个命令流程。
|
||||
|
||||
## 后续工作
|
||||
|
||||
- `dsh-sdk add [package-spec]`:统一本地插件创建与外部 Cordis 插件接入;未指定 package 或仓库来源时创建本地 plugin/tool,指定来源时增加 NPM 依赖和 `cordis.yml` 配置项,来源模型为 GitHub 仓库等扩展保留空间
|
||||
- 非交互 create/config:本期两个流程都要求 TTY,不提供供自动化调用的完整输入合同
|
||||
- 更多功能专用参数输入:本期产品只展示有限功能选项、secret 和少量专用值,不为 Cordis 插件配置提供通用参数界面
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**不可编辑的 preset 或生成器托管工程。** 该方案可以缩短初次创建路径,但会隐藏真实插件树和构建边界,使高级开发者无法直接组合 Cordis 插件,也让项目行为依赖 CLI 版本而不是检入的工程文件。
|
||||
|
||||
**只提供一次性生成器。** 创建后完全依赖手工维护,会让功能依赖、功能选项切换和多文件更新再次分散;共享 registry 的 config 流程为生成工程保留持续管理机制。
|
||||
|
||||
**为开发和生产维护两份 `cordis.yml`。** 两份插件树会使开发成功无法证明生产加载相同功能;dev 只增加 TypeScript 与本地 workspace 解析,运行配置保持唯一。
|
||||
|
||||
**为任意 Cordis 插件配置生成通用表单。** Cordis 插件配置包含嵌套结构、表达式和插件特有语义,通用表单会形成第二套不完整 schema。SDK 只管理有限功能选项和专用 secret,复杂配置继续由开发者直接编辑。
|
||||
|
||||
**使用私有协议发现本地插件。** 普通 package manager workspace、根 NPM 依赖、TypeScript references 和 Cordis 配置项已能表达完整关系;额外发现协议会创造只能由 SDK 理解的隐藏状态。
|
||||
|
||||
**在现有工程中提供 `dsh-sdk create`。** create 已能生成一种可编辑的本地插件骨架,后续插件可以沿用普通 workspace 和 Cordis 机制手工添加;再提供同构命令会增加第二条脚手架产品面,却不增加新的组合功能。
|
||||
|
||||
**把每个新 Cordis 插件自动暴露为 builtin。** package 无法说明多个插件如何组合成一项产品功能,也无法推导互斥关系、功能依赖、secret、接口适用性和安全限制;支持集需要人工策划,自动化只适合检查候选是否完成分类。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `npm create @deepseek-ai/sdk` 按本文顺序收集项目身份、provider、interface、功能、可选本地插件、包管理器和安装选择,并在取消时保持目标路径不存在
|
||||
- 默认 npm 工程具有本文目录树和 `dev`、`build`、`typecheck`、`start`、`config` scripts,且 dev/start 使用同一份 `cordis.yml`
|
||||
- create 展示本文功能及功能选项;`bash` 的 local/sandbox 二选一且默认 local,sandbox Cordis 配置项保留可编辑的注释配置示例;HMR 默认选中并同时由 dev/start 加载
|
||||
- create 的 `plugin` 或 `tool` 选择至多生成一个固定名称的本地插件,并原子更新插件文件与根工程关系;本期不提供 `dsh-sdk create`
|
||||
- `dsh-sdk config` 从现有工程读取同一支持集,能够安装、启停和切换支持的功能选项,保留未知配置与注释,并拒绝修改不一致配置
|
||||
- `.env.example` 反映当前功能所需变量;`.env` 只追加缺失的不同名变量,从不覆盖或清理已有内容
|
||||
- npm、pnpm 和 Yarn 生成的 workspace 能安装、构建和启动;本地插件在 dev 中使用源码,在 start 中使用构建产物
|
||||
|
||||
## 风险
|
||||
|
||||
- 开发者可以把 builtin 手改成 registry 无法识别的形状;SDK 选择停止自动化而不是猜测并覆盖配置
|
||||
- 多文件写入前的校验和外部修改检测不能提供写入阶段的事务回滚;I/O 中途失败可能留下需要人工修复的部分提交
|
||||
- sandbox 功能选项依赖目标平台存在可用的本地沙箱后端;后端不可用时必须 fail closed,不能退回无沙箱执行
|
||||
- HMR 在生产启动中也保持文件 watcher 和热重载行为;这是显式插件选择的结果,不是仅限开发环境的隐式服务
|
||||
- `.env` 的仅追加策略会保留已经不用的凭据,SDK 不判断这些用户数据何时可以安全删除
|
||||
@@ -102,6 +102,15 @@
|
||||
"packages/examples/jsonrpc-demo": {
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/sdk/create-sdk": {
|
||||
"entry": ["src/bin.ts", "tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/**/*.snapshot.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
},
|
||||
"packages/sdk/scripts": {
|
||||
"entry": ["src/bin.ts", "tests/**/*.spec.ts", "tests/**/*.snapshot.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"ignoreDependencies": ["node-addon-require-builtin"]
|
||||
},
|
||||
"packages/subagent/subagent-spawn": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
|
||||
@@ -28,6 +28,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Sandbox-consuming implementation of the [`@deepseek-ai/dsh-bash`](../bash/) executor seam. Load it **instead of** `@deepseek-ai/dsh-bash-local`, together with a [`ctx.sandbox`](../../sandbox/sandbox/) provider (e.g. [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/)) — no alternate tool plugin is needed; `dsh-tool-bash` detects the executor's `sandboxMode` capability and adds the escalation fields.
|
||||
|
||||
The package root exports the default and named `SandboxBashExecutor` plugin plus its `Config`; quoting and result-classification helpers stay internal.
|
||||
|
||||
Every command is confined by handing the provider the exact `['bash', '-c', command]` argv this executor is about to spawn and spawning the returned (wrapped) argv instead. WHICH platform runner confines it — and whether one is usable at all (fail closed with a structured `SANDBOX_UNAVAILABLE` error, never a silent unconfined run) — is the provider's concern; this package owns the bash side only.
|
||||
|
||||
| Mode | File effects |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Internal shell-quoting and sandbox-result classification helpers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-sandbox/helpers
|
||||
*/
|
||||
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word.
|
||||
* @param text - raw argv element to preserve through the outer shell parse.
|
||||
* @returns the quoted shell word.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's denial dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive denial substrings from the active wrap.
|
||||
* @returns whether the failed run matches that denial dialect.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a failed run against the selected backend's runner-failure dialect.
|
||||
* @param result - settled foreground run.
|
||||
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
|
||||
* @returns whether the failed run matches that runner-failure dialect.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a non-zero exit against case-insensitive stderr signatures.
|
||||
* @param exitCode - process exit code; null means signal termination.
|
||||
* @param stderr - collected stderr text.
|
||||
* @param signatures - substrings identifying the selected backend's dialect.
|
||||
* @returns whether this is a non-zero exit whose stderr matches a signature.
|
||||
*/
|
||||
export function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
|
||||
import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } from './helpers.ts'
|
||||
|
||||
/**
|
||||
* Plugin config: the local executor's knobs plus the sandbox policy. All
|
||||
@@ -33,52 +34,6 @@ export interface Config extends LocalConfig {
|
||||
workspaceRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one string as a single-quoted POSIX shell word (embedded single
|
||||
* quotes become `'\''`), so a wrapped argv element survives the outer
|
||||
* `bash -c` re-parse byte-for-byte.
|
||||
* @param text - the raw argv element to quote.
|
||||
* @returns the single-quoted shell word.
|
||||
*/
|
||||
export function shellQuote(text: string): string {
|
||||
return `'${text.replaceAll("'", String.raw`'\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservatively classify a nonzero, non-signal run using only the selected
|
||||
* backend's denial signatures. Text inference may miss a denial or match
|
||||
* unrelated stderr in that dialect; it never uses another backend's terms.
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's denial dialect, case-insensitive stderr substrings.
|
||||
* @returns whether the run's failure reads as a sandbox denial.
|
||||
*/
|
||||
export function classifyDenial(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a nonzero run using the selected backend's runner-failure
|
||||
* signatures. Callers check this before denial because runner diagnostics may
|
||||
* contain denial words; the command did not run.
|
||||
* @param result - the settled foreground run to classify.
|
||||
* @param signatures - the active wrap's runner-failure signatures,
|
||||
* case-insensitive stderr substrings.
|
||||
* @returns whether the run's failure reads as the runner itself failing.
|
||||
*/
|
||||
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
|
||||
return matchesSignature(result.exitCode, result.stderr.text, signatures)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared classifier for failed runs. Signatures are case-insensitive and may
|
||||
* include runtime values such as an executable path.
|
||||
*/
|
||||
function matchesSignature(exitCode: number | null, stderr: string, signatures: readonly string[]): boolean {
|
||||
if (exitCode === null || exitCode === 0) return false
|
||||
const lowered = stderr.toLowerCase()
|
||||
return signatures.some(signature => lowered.includes(signature.toLowerCase()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers as `ctx.bash` in place of the local executor and requires a
|
||||
* `ctx.sandbox` provider; the tool layer is unchanged. The configured mode is
|
||||
|
||||
@@ -5,7 +5,8 @@ import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Context } from 'cordis'
|
||||
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, SandboxBashExecutor, shellQuote } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts'
|
||||
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
|
||||
|
||||
@@ -5,7 +5,8 @@ import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
|
||||
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,22 @@ function fixture(files: Record<string, string>): string {
|
||||
const make = (content: string): string => fixture({ 'index.ts': content })
|
||||
|
||||
describe('verify-export-jsdoc functions and consts', () => {
|
||||
it('limits packages without src/* exports to declarations reachable from package entrypoints', () => {
|
||||
const root = fixture({
|
||||
'index.ts': "export { publicFn } from './internal.ts'\n",
|
||||
'internal.ts': `
|
||||
export function publicFn(value: string): string { return value }
|
||||
export function hiddenFn(value: string): string { return value }
|
||||
`,
|
||||
})
|
||||
writeFileSync(join(root, 'packages/group/fix/package.json'), JSON.stringify({
|
||||
exports: { '.': { types: './lib/types/index.d.ts', default: './lib/index.js' } },
|
||||
}))
|
||||
const violations = collectExportJsdocViolations(root)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations.every(violation => violation.includes('publicFn'))).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a fully documented surface', () => {
|
||||
expect(collectExportJsdocViolations(make(`
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly.
|
||||
|
||||
The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal.
|
||||
|
||||
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
|
||||
|
||||
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { grantArgs as landlockGrantArgs, LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
|
||||
import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
@@ -38,77 +37,6 @@ export interface Config {
|
||||
probeTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bwrap profile: the host is read-only with fresh `/dev` and `/proc`;
|
||||
* workspace-write overlays writable temp and workspace mounts. PID and network
|
||||
* isolation are intentionally outside the file-effect policy.
|
||||
*
|
||||
* @param policy - the file-effect policy to express as bwrap arguments.
|
||||
* @returns the bwrap profile arguments (before the trailing `--` + argv).
|
||||
*/
|
||||
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
args.push('--tmpfs', '/tmp')
|
||||
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Landlock grants for the same file policy without synthetic mounts.
|
||||
* Read-only grants only `/dev/null` for writes; workspace-write also grants the
|
||||
* host temp root and workspace.
|
||||
*
|
||||
* @param policy - the file-effect policy to express as launcher grants.
|
||||
* @returns the launcher grant arguments (before `--` + argv).
|
||||
*/
|
||||
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const readWrite = ['/dev/null']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
readWrite.push('/tmp', policy.workspaceRoot)
|
||||
}
|
||||
return landlockGrantArgs({ readOnly: ['/'], readWrite })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a granted root to the path the kernel actually sees. Seatbelt path
|
||||
* filters match the CANONICAL path (symlinks resolved), and the roots this
|
||||
* profile grants are symlinked on every macOS: `/tmp` is `/private/tmp` and
|
||||
* the user temp dir lives under `/var` → `/private/var` — an as-spelled
|
||||
* grant would match nothing.
|
||||
*/
|
||||
function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// An unresolved grant matches nothing until the named path exists; keep its spelling.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote one path as an SBPL string literal (backslashes and double quotes escaped). */
|
||||
function sbplString(path: string): string {
|
||||
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Seatbelt profile that denies file writes then allows `/dev/null` and,
|
||||
* for workspace-write, the canonical workspace, host temp, and per-user macOS
|
||||
* temp roots. Network and process visibility remain unrestricted.
|
||||
*
|
||||
* @param policy - the file-effect policy to express as an SBPL profile.
|
||||
* @returns the `sandbox-exec` arguments (`-p` + profile, before `--` + argv).
|
||||
*/
|
||||
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
|
||||
if (policy.mode === 'workspace-write') {
|
||||
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
|
||||
}
|
||||
return ['-p', forms.join(' ')]
|
||||
}
|
||||
|
||||
/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */
|
||||
function defaultProbeBwrap(timeoutMs: number): boolean {
|
||||
const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Internal platform-profile builders for the local sandbox provider.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sandbox-local/profiles
|
||||
*/
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { grantArgs as landlockGrantArgs } from 'node-addon-landlock-run'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/**
|
||||
* Build the bwrap profile arguments for one file-effect policy.
|
||||
* @param policy - file-effect policy to express as bwrap mounts.
|
||||
* @returns profile arguments before the trailing separator and command argv.
|
||||
*/
|
||||
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
args.push('--tmpfs', '/tmp')
|
||||
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Landlock launcher grants for one file-effect policy.
|
||||
* @param policy - file-effect policy to express as Landlock allow-list grants.
|
||||
* @returns launcher grant arguments before the trailing separator and command argv.
|
||||
*/
|
||||
export function landlockProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const readWrite = ['/dev/null']
|
||||
if (policy.mode === 'workspace-write') {
|
||||
readWrite.push('/tmp', policy.workspaceRoot)
|
||||
}
|
||||
return landlockGrantArgs({ readOnly: ['/'], readWrite })
|
||||
}
|
||||
|
||||
/** Resolve a granted root to the canonical path the Seatbelt kernel sees. */
|
||||
function canonicalPath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path)
|
||||
} catch {
|
||||
// Missing or unreadable roots stay as spelled; an unresolved root grants
|
||||
// nothing until it exists, which is the conservative outcome.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/** Quote one path as an SBPL string literal. */
|
||||
function sbplString(path: string): string {
|
||||
return `"${path.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`)}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sandbox-exec arguments and SBPL profile for one policy.
|
||||
* @param policy - file-effect policy to express as an SBPL profile.
|
||||
* @returns sandbox-exec arguments before the trailing separator and command argv.
|
||||
*/
|
||||
export function seatbeltProfileArgs(policy: SandboxPolicy): string[] {
|
||||
const forms = ['(version 1)', '(allow default)', '(deny file-write*)', `(allow file-write* (literal ${sbplString('/dev/null')}))`]
|
||||
if (policy.mode === 'workspace-write') {
|
||||
const roots = [...new Set([policy.workspaceRoot, '/tmp', tmpdir()].map(canonicalPath))]
|
||||
forms.push(`(allow file-write* ${roots.map(root => `(subpath ${sbplString(root)})`).join(' ')})`)
|
||||
}
|
||||
return ['-p', forms.join(' ')]
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { bwrapProfileArgs, LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { bwrapProfileArgs } from '../src/profiles.ts'
|
||||
|
||||
/**
|
||||
* Keyless backend integration through `confine()` and a real bwrap process. With no rung forced,
|
||||
|
||||
@@ -15,12 +15,10 @@ import { Context } from 'cordis'
|
||||
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import {
|
||||
bwrapProfileArgs,
|
||||
landlockProfileArgs,
|
||||
LocalSandboxProvider,
|
||||
seatbeltProfileArgs,
|
||||
} from '@deepseek-ai/dsh-sandbox-local'
|
||||
import type { Config } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../src/profiles.ts'
|
||||
|
||||
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
|
||||
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
|
||||
|
||||
@@ -6,7 +6,8 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import { LocalSandboxProvider, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
|
||||
import { seatbeltProfileArgs } from '../src/profiles.ts'
|
||||
|
||||
/**
|
||||
* Keyless backend integration through `confine()` and a real macOS Seatbelt process, with Linux
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# SDK packages
|
||||
|
||||
Developer tooling for creating, editing, building, and running DeepSeek Harness projects.
|
||||
|
||||
The [feature RFC](../../docs/rfc/proposed/feature/2026-07-14-sdk-developer-projects.md) owns the developer workflow; the [architecture RFC](../../docs/rfc/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the package and project-editing boundaries.
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| [`helper`](helper/README.md) | Project aggregate, edit session, builtin features, project documents, templates, package managers, and prompt abstraction |
|
||||
| [`scripts`](scripts/README.md) | The `dsh-sdk` launcher: `start`, `dev`, `build`, and interactive `config` |
|
||||
| [`create-sdk`](create-sdk/README.md) | The `npm create @deepseek-ai/sdk` initializer |
|
||||
|
||||
`@deepseek-ai/create-sdk` is the one package-name exception to the repository's `@deepseek-ai/dsh-*` rule: npm's scoped initializer convention requires that name for `npm create @deepseek-ai/sdk`.
|
||||
|
||||
Generated projects keep `cordis.yml` as the only runtime plugin tree. `dsh-sdk dev` adds TypeScript and local-workspace resolution around that same file; it does not create a development-only config.
|
||||
@@ -0,0 +1,19 @@
|
||||
# `@deepseek-ai/create-sdk`
|
||||
|
||||
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`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY.
|
||||
|
||||
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.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/bin.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-helper": "workspace:^",
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
runInterface?: RunInterface
|
||||
packageManager?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
interface CommanderCreateOptions {
|
||||
description?: string
|
||||
provider?: 'deepseek' | 'custom'
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
interface?: RunInterface
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: 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', 'custom']))
|
||||
.option('--base-url <url>')
|
||||
.option('--api-key <key>')
|
||||
.option('--model <name>')
|
||||
.addOption(new Option('--interface <name>').choices(['acp', 'stdio', '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')
|
||||
}
|
||||
|
||||
/** 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 } : {},
|
||||
help: options.help ?? false,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Internal create-sdk command composition used by the package bin.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/command
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import {
|
||||
ClackPromptPort,
|
||||
PromptCancelledError,
|
||||
type PackageManagerVersionProbe,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { parseCreateArgs } from './args.ts'
|
||||
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.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)
|
||||
if (args.help) {
|
||||
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
|
||||
return undefined
|
||||
}
|
||||
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('create-sdk requires an interactive TTY')
|
||||
}
|
||||
const wizard = new CreateWizard({
|
||||
args,
|
||||
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
|
||||
port: context.port ?? new ClackPromptPort(context.stdin, context.stdout),
|
||||
cwd: context.cwd,
|
||||
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
|
||||
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
|
||||
})
|
||||
const resolved = await wizard.run()
|
||||
const result = await scaffoldProject(resolved.directory, resolved.request)
|
||||
context.stdout.write(CREATE_TEMPLATES.created.render({
|
||||
name: resolved.request.name,
|
||||
directory: resolved.directory,
|
||||
}))
|
||||
if (resolved.install) {
|
||||
try {
|
||||
if (context.setup) await context.setup(resolved)
|
||||
else {
|
||||
await resolved.request.packageManager.install(resolved.directory)
|
||||
await resolved.request.packageManager.build(resolved.directory)
|
||||
}
|
||||
} catch (error) {
|
||||
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
|
||||
directory: resolved.directory,
|
||||
error: String(error),
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
|
||||
directory: resolved.directory,
|
||||
setupRequired: !resolved.install,
|
||||
...packageManagerTemplateModel(resolved.request.packageManager),
|
||||
}))
|
||||
return result
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
try {
|
||||
await createProject(argv, context)
|
||||
return 0
|
||||
} catch (error) {
|
||||
if (error instanceof PromptCancelledError) {
|
||||
context.stderr.write('create-sdk: cancelled\n')
|
||||
return 1
|
||||
}
|
||||
context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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' | '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' | 'custom'>({
|
||||
id: 'provider',
|
||||
message: 'Model provider',
|
||||
options: [
|
||||
{ value: 'deepseek', label: 'DeepSeek' },
|
||||
{ value: 'custom', label: 'Custom endpoint (pi-ai)' },
|
||||
],
|
||||
initialValue: 'deepseek',
|
||||
}),
|
||||
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 server' },
|
||||
{ value: 'stdio', label: 'Terminal REPL' },
|
||||
{ value: 'embed', label: 'Embedded context' },
|
||||
],
|
||||
initialValue: 'stdio',
|
||||
}),
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
/** Bind parsed args and infrastructure to one wizard run. */
|
||||
constructor(options: {
|
||||
args: CreateArgs
|
||||
port: PromptPort
|
||||
cwd?: string
|
||||
releaseVersion: string
|
||||
versionProbe?: PackageManagerVersionProbe
|
||||
userAgent?: string
|
||||
}) {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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 = [...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),
|
||||
})),
|
||||
} : {},
|
||||
}
|
||||
}),
|
||||
}))]
|
||||
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)
|
||||
}
|
||||
for (const [id, options] of choices) {
|
||||
selections.push(await configurator.configure(
|
||||
registry.get(id),
|
||||
profile,
|
||||
undefined,
|
||||
options,
|
||||
))
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* The create-sdk package is a CLI initializer; its library entry exports no symbols.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk
|
||||
*/
|
||||
|
||||
export {}
|
||||
@@ -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,11 @@
|
||||
Usage: create-sdk [directory] [options]
|
||||
|
||||
Options:
|
||||
--description <text>
|
||||
--provider <deepseek|custom>
|
||||
--base-url <url>
|
||||
--api-key <key>
|
||||
--model <name>
|
||||
--interface <acp|stdio|embed>
|
||||
--pm <npm|pnpm|yarn>
|
||||
--install / --no-install
|
||||
@@ -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
|
||||
@@ -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/sdk/create-sdk/lib/bin.js')
|
||||
const scriptsBin = join(repoRoot, 'packages/sdk/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('')
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,398 @@
|
||||
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('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',
|
||||
'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",
|
||||
"kind": "select",
|
||||
"message": "Model provider",
|
||||
"options": [
|
||||
"DeepSeek",
|
||||
"Custom endpoint (pi-ai)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"kind": "secret",
|
||||
"message": "DeepSeek API key",
|
||||
},
|
||||
{
|
||||
"initialValue": "stdio",
|
||||
"kind": "select",
|
||||
"message": "Run interface",
|
||||
"options": [
|
||||
"ACP server",
|
||||
"Terminal REPL",
|
||||
"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,
|
||||
},
|
||||
{
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"label": "Ask the user from the model loop",
|
||||
"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",
|
||||
],
|
||||
},
|
||||
{
|
||||
"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",
|
||||
},
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,502 @@
|
||||
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 {
|
||||
LocalPluginBlueprint,
|
||||
featureId,
|
||||
NpmPackageManager,
|
||||
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 { 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', '--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',
|
||||
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, stdio, 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',
|
||||
'--api-key=deepseek-key',
|
||||
'--model=deepseek-v4-flash',
|
||||
'--interface=stdio',
|
||||
'--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('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'], 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" }')
|
||||
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', '--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',
|
||||
'--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 start; an empty value makes provider startup fail.\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'] },
|
||||
],
|
||||
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'] },
|
||||
{ value: featureId('timeout-policy'), choices: ['default'] },
|
||||
],
|
||||
'none',
|
||||
])
|
||||
const resolved = await new CreateWizard({
|
||||
args: parseCreateArgs([
|
||||
'agent', '--description=test', '--provider=deepseek', '--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', '--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', '--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()).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 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()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { 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/sdk/scripts/lib/bin.js')
|
||||
const temporary: string[] = []
|
||||
|
||||
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'], 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 commandEnvironment = {
|
||||
...scrubEnvironment(),
|
||||
COREPACK_HOME: join(cacheRoot, 'corepack'),
|
||||
XDG_CACHE_HOME: join(cacheRoot, 'cache'),
|
||||
XDG_DATA_HOME: join(cacheRoot, 'data'),
|
||||
npm_config_cache: join(cacheRoot, 'npm'),
|
||||
pnpm_config_store_dir: join(cacheRoot, 'pnpm-store'),
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../helper" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
]
|
||||
}
|
||||
@@ -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/bin.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
# `@deepseek-ai/dsh-helper`
|
||||
|
||||
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 RFC](../../../docs/rfc/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, timeout policy, and ask-user. 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.
|
||||
|
||||
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. 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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"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",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-hooks-claude": "workspace:^",
|
||||
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-web": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Required run-interface app feature.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/app
|
||||
*/
|
||||
|
||||
import { featureId } from '../../ids.ts'
|
||||
import type { ProjectProfile } 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,
|
||||
optionalString,
|
||||
ownedTextFile,
|
||||
packageScript,
|
||||
requiredString,
|
||||
} from './helpers.ts'
|
||||
|
||||
const ID = featureId('app')
|
||||
|
||||
function appProjectResources(
|
||||
profile: ProjectProfile,
|
||||
runInterface: 'acp' | 'stdio' | 'embed',
|
||||
): readonly ProjectResource[] {
|
||||
const context = createProjectTemplateContext(profile, runInterface)
|
||||
const scripts = createAppPackageScripts(context)
|
||||
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: 'acp' | 'stdio' | 'embed'
|
||||
override readonly label: string
|
||||
|
||||
constructor(id: 'acp' | 'stdio' | 'embed', 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 'stdio': return [{ id: 'stdio', name: '@deepseek-ai/dsh-stdio' }]
|
||||
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' || entry.name === '@deepseek-ai/dsh-stdio')
|
||||
}
|
||||
|
||||
override contribution(profile: ProjectProfile): ProjectContribution {
|
||||
switch (this.id) {
|
||||
case 'acp':
|
||||
return new ProjectContribution([
|
||||
...appProjectResources(profile, this.id),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'user-interaction',
|
||||
name: '@deepseek-ai/dsh-user-interaction',
|
||||
}),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'acp',
|
||||
name: '@deepseek-ai/dsh-acp',
|
||||
config: { model: profile.runtime.model },
|
||||
}, ['model'], config => requiredString(config, 'model')),
|
||||
])
|
||||
case 'stdio':
|
||||
return new ProjectContribution([
|
||||
...appProjectResources(profile, this.id),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'user-interaction',
|
||||
name: '@deepseek-ai/dsh-user-interaction',
|
||||
}),
|
||||
...npmCordisConfigEntry(ID, {
|
||||
id: 'stdio',
|
||||
name: '@deepseek-ai/dsh-stdio',
|
||||
config: {
|
||||
welcome: 'agent REPL ready. Give it a coding task.',
|
||||
agent: 'main',
|
||||
},
|
||||
}, ['welcome', 'agent'], config => [
|
||||
...optionalString(config, 'welcome'),
|
||||
...requiredString(config, 'agent'),
|
||||
]),
|
||||
])
|
||||
case 'embed':
|
||||
return new ProjectContribution(appProjectResources(profile, this.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Required app selection represented by acp, stdio, 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 server'),
|
||||
new AppOption('stdio', 'Terminal REPL'),
|
||||
new AppOption('embed', 'Embedded context'),
|
||||
]
|
||||
|
||||
/** Default to the profile's already selected front door. */
|
||||
override defaultOptions(profile: ProjectProfile): readonly string[] {
|
||||
return [profile.runInterface]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** Create a runtime NPM dependency resource. */
|
||||
function npmDependency(_owner: string, name: string): NpmDependencyResource {
|
||||
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 Cordis config entry to its mandatory runtime NPM dependency. */
|
||||
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`]
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* 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 { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
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 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'
|
||||
|
||||
const compactPreset = {
|
||||
contextWindow: 128_000,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20_480,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8_192,
|
||||
compactionRetries: 1,
|
||||
} satisfies BasicCompactConfig
|
||||
|
||||
/**
|
||||
* 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: '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' }],
|
||||
}],
|
||||
},
|
||||
{
|
||||
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',
|
||||
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',
|
||||
baseResources: [{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' }],
|
||||
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' } 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' } 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: 'compact-basic',
|
||||
package: '@deepseek-ai/dsh-compact-basic',
|
||||
config: compactPreset,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
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',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ask-user',
|
||||
summary: 'Ask the user from the model loop',
|
||||
mode: 'single',
|
||||
supportedInterfaces: ['acp', 'stdio'],
|
||||
options: [{
|
||||
id: 'default',
|
||||
label: 'ask_user_question tool',
|
||||
default: true,
|
||||
resources: [{
|
||||
kind: 'npm-cordis-config-entry',
|
||||
id: 'tool-ask-user',
|
||||
package: '@deepseek-ai/dsh-tool-ask-user',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]), profile)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Required hand-rolled DeepSeek and custom pi-ai provider behavior.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-helper/features/builtin/provider
|
||||
*/
|
||||
|
||||
import { JsExpression } from '../../documents/cordis-yaml-file.ts'
|
||||
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 start; an empty value makes provider startup fail.'
|
||||
|
||||
class DeepSeekOption extends FeatureOption {
|
||||
override readonly id = 'deepseek'
|
||||
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',
|
||||
config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') },
|
||||
}, ['apiKey', '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',
|
||||
config: { apiKey: new JsExpression('process.env.DEEPSEEK_API_KEY') },
|
||||
}, ['apiKey', '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 hand-rolled adapter and its public endpoint defaults. */
|
||||
override defaultOptions(): readonly string[] {
|
||||
return ['deepseek']
|
||||
}
|
||||
|
||||
/** 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] },
|
||||
},
|
||||
},
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { 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' }),
|
||||
...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()]
|
||||
}
|
||||
@@ -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', 'stdio', '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))
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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.
|
||||
* @returns normalized selection with captured values and secrets.
|
||||
*/
|
||||
async configure(
|
||||
feature: Feature,
|
||||
profile: ProjectProfile,
|
||||
current?: FeatureSelection,
|
||||
prefilledOptions?: readonly string[],
|
||||
prefilledSecrets: Readonly<Record<string, string>> = {},
|
||||
): 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 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))
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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', 'stdio', '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'
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared domain and infrastructure for DeepSeek Harness SDK project tooling.
|
||||
*
|
||||
* @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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 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 { 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. */
|
||||
export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
|
||||
}
|
||||
|
||||
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
||||
export class NodeCommandRunner implements CommandRunner {
|
||||
/** Spawn one child and settle only after its exit. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, [...args], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', (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')
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* 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()) {
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
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 !== 'stdio' && 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')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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-acp')) return 'acp'
|
||||
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-stdio')) return 'stdio'
|
||||
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\/sdk\/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 !== 'stdio' && 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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' | 'stdio' | '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 ?? [],
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# {{name}}
|
||||
|
||||
{{description}}
|
||||
|
||||
Built with the DeepSeek Harness SDK using the {{model}} model.
|
||||
|
||||
{{#if isAcp}}
|
||||
## Run as an ACP server
|
||||
|
||||
Run `{{packageManager}} start` and configure your ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
|
||||
{{else}}
|
||||
{{#if isStdio}}
|
||||
## Run in a terminal
|
||||
|
||||
Run `{{packageManager}} start` to start the interactive agent.
|
||||
{{else}}
|
||||
## Embed the harness
|
||||
|
||||
Import and call the exported `main()` from `index.ts` in your host application.
|
||||
{{/if}}
|
||||
{{/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
|
||||
@@ -0,0 +1,45 @@
|
||||
{{#if isAcp}}
|
||||
import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts'
|
||||
{{else}}
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
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 isStdio}}
|
||||
const model = boot.args.model
|
||||
if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=<name>')
|
||||
const resume = boot.args.resume
|
||||
if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) {
|
||||
throw new Error('stdio startup requires --resume=<session-id>')
|
||||
}
|
||||
if (resume === undefined) {
|
||||
await ctx.agents.create({
|
||||
agentId: AgentId('main'),
|
||||
sessionId: SessionId(`main-session-${randomUUID()}`),
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model },
|
||||
})
|
||||
} else {
|
||||
await ctx.agents.resume({
|
||||
agentId: AgentId('main'),
|
||||
resumeSessionId: SessionId(resume),
|
||||
agentOptions: { model },
|
||||
})
|
||||
}
|
||||
{{else}}
|
||||
{{#if isEmbed}}
|
||||
await ctx.agents.create({
|
||||
agentId: AgentId('main'),
|
||||
sessionId: SessionId(`main-session-${randomUUID()}`),
|
||||
meta: { cwd: boot.cwd },
|
||||
agentOptions: { model: {{modelLiteral}} },
|
||||
})
|
||||
{{/if}}
|
||||
{{/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
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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
|
||||
isStdio: 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',
|
||||
isStdio: runInterface === 'stdio',
|
||||
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(context: ProjectTemplateContext): Readonly<Record<'dev' | 'start', string>> {
|
||||
const modelArg = context.isStdio ? ` -- --model=${JSON.stringify(context.model)}` : ''
|
||||
return {
|
||||
dev: `dsh-sdk dev index.ts${modelArg}`,
|
||||
start: `dsh-sdk start index.js${modelArg}`,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
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: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
custom: keep
|
||||
`)
|
||||
const apiKey = document.entry('provider')?.config?.apiKey
|
||||
expect(apiKey).toBeInstanceOf(JsExpression)
|
||||
document.updateOwnedConfig('provider', ['apiKey'], { apiKey: new JsExpression('process.env.NEXT_KEY') })
|
||||
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_KEY')
|
||||
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, isStdio: 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('@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']])
|
||||
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', 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()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,954 @@
|
||||
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' | 'stdio' | 'embed' = 'stdio',
|
||||
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'], { 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)
|
||||
const stdio = await make('dsh-open-stdio', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
- id: stdio
|
||||
name: '@deepseek-ai/dsh-stdio'
|
||||
`, { 'yarn.lock': '' })
|
||||
expect(stdio.profile.runInterface).toBe('stdio')
|
||||
expect(stdio.profile.runtime.model).toBe('provider-model')
|
||||
expect(stdio.profile.packageManager.name).toBe('yarn')
|
||||
expect(stdio.profile.name).toBe(stdio.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: stdio
|
||||
name: '@deepseek-ai/dsh-stdio'
|
||||
config: { model: '' }
|
||||
- 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).toContain('boot.args.resume')
|
||||
expect(project.packageManifest().scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"',
|
||||
build: 'dsh-sdk build',
|
||||
typecheck: 'tsc -b',
|
||||
start: 'dsh-sdk start index.js -- --model="deepseek-v4-flash"',
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
|
||||
expect(project.cordis.entry('stdio')?.config).toMatchObject({ agent: 'main' })
|
||||
expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model')
|
||||
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
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).not.toHaveProperty('node-addon-require-builtin')
|
||||
expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
|
||||
})
|
||||
|
||||
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()
|
||||
expect(committed.cordis.entry('stdio')).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([], [], 'stdio', '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.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 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')
|
||||
expect(await readFile(join(embed.root, 'index.ts'), 'utf8')).toContain('agents.create')
|
||||
|
||||
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', ['stdio']),
|
||||
) }).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('rejects enabled features that do not apply to the target app interface', async () => {
|
||||
const project = await createCommitted([selection('ask-user', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.configureFeature(registry.get(featureId('app')), selection('app', ['embed']))
|
||||
await expect(edit.commit()).rejects.toThrow('feature ask-user is not available for embed')
|
||||
})
|
||||
|
||||
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']))
|
||||
edit.disableFeature(todo)
|
||||
edit.configureFeature(todo, selection('todo', ['default']))
|
||||
edit.enableFeature(todo)
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('ask-user'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('ask-user'))) }).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('stdio', 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:stdio'),
|
||||
entry: { id: 'stdio', 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:
|
||||
apiKey: test
|
||||
`)
|
||||
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'])) }).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'])) }).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']))
|
||||
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'], { 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']))
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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: 'stdio' })).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: 'stdio' }))
|
||||
.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: 'stdio' }))
|
||||
.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(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: 'stdio', name: '@deepseek-ai/dsh-stdio' },
|
||||
], 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'])
|
||||
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'],
|
||||
}, { ...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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,453 @@
|
||||
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: 'stdio',
|
||||
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', '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'], 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'], { apiKey: '' },
|
||||
)).rejects.toThrow('required')
|
||||
const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek', '']))
|
||||
await expect(keep.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek'], 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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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-persistence/session-persistence-jsonl" },
|
||||
{ "path": "../../session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "../../subagent/tool-subagent" },
|
||||
{ "path": "../../web/tool-web" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
]
|
||||
}
|
||||
@@ -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'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
# `@deepseek-ai/dsh-scripts`
|
||||
|
||||
The `dsh-sdk` launcher owns SDK project startup and configuration.
|
||||
|
||||
| Command | Behavior |
|
||||
|---|---|
|
||||
| `dsh-sdk start [target] [-- args…]` | Import a module target and invoke `main(bootContext)`, or boot `cordis.yml` when omitted; arguments after `--` are forwarded |
|
||||
| `dsh-sdk dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path |
|
||||
| `dsh-sdk build [args…]` | Invoke the project's installed tsdown with the project arguments |
|
||||
| `dsh-sdk config` | Open one interactive edit session, review accumulated changes, commit once, and install once when NPM dependencies changed |
|
||||
|
||||
`ProjectBuild(tsdownConfig)` and `PluginBuild(tsdownConfig)` are exported only from `@deepseek-ai/dsh-scripts/dev/tsdown-config`. Development and production read the same `cordis.yml`.
|
||||
|
||||
Generated project scripts invoke `dsh-sdk` for dev, build, start, and config; typecheck runs `tsc -b` directly. HMR remains an explicit `cordis.yml` feature loaded by both dev and start.
|
||||
|
||||
The runtime library exports `startSDK(source)` to load `.env` and `cordis.yml` and return the live context, and `runSDK(target)` to import a project module and invoke its `main(bootContext)` (`runSDK()` without a target delegates to `startSDK('./cordis.yml')`). `SdkBootContext` carries the raw forwarded `argv`, generic `args`, the absolute launcher `cwd`, and the `start`/`dev` mode. The launcher declares no project options: Node `parseArgs()` runs with zero schema, so valued flags use `--key=value`, bare flags become booleans, `--no-cache` becomes `args.cache = false`, and option names retain Node's spelling (`--max-depth=3` → `args['max-depth']`).
|
||||
|
||||
`start` never builds. `dev` registers the project-installed tsx transform plus an exact package-name map from `plugins/*/package.json` to each `src/index.ts`, then follows the same start path. `build` invokes the project-installed tsdown and forwards its arguments; an absent tsdown config is a successful no-op.
|
||||
|
||||
`config` requires a TTY. One feature tree selects the desired enabled set; changed rows are highlighted, Right changes finite feature options, required rows cannot be deselected, inconsistent rows show diagnostics, and custom/manual Cordis config entries support enable/disable. The workflow reconciles that target into one edit session. Review & Apply commits once, then NPM dependency changes trigger one package-manager install. A failed install does not undo committed files.
|
||||
|
||||
The root library exports `startSDK`, `runSDK`, and the `SdkBootArgs`/`SdkBootContext` types; command composition remains private to the bin. No `src/*`, bin, or package-manifest subpath is exported.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the project `cordis.yml` tree loaded by `start` or `dev`.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Launcher arguments are schema-free** — `start` and `dev` preserve Node `parseArgs()` output rather than validating project-specific flags.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-scripts",
|
||||
"description": "DeepSeek Harness SDK launcher for start, dev, build, and project configuration",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"bin": {
|
||||
"dsh-sdk": "lib/bin.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./dev/tsdown-config": {
|
||||
"types": "./lib/types/dev/tsdown-config.d.ts",
|
||||
"default": "./lib/dev/tsdown-config.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/bin.js",
|
||||
"lib/dev/tsdown-config.js",
|
||||
"lib/local-plugin-loader-hooks.js",
|
||||
"lib/assets",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-helper": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"node-addon-require-builtin": "^0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"tsdown": "^0.22.2",
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"tsdown": { "optional": true },
|
||||
"tsx": { "optional": true }
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"tsdown": "^0.22.2",
|
||||
"tsx": "^4.22.4"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user