feat(sdk): add developer project tooling
docs(rfc): propose SDK developer project tooling feat: rename / docs ci: fix windows gates docs: revert
This commit is contained in:
30
packages/sdk/scripts/README.md
Normal file
30
packages/sdk/scripts/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# `@deepseek-ai/dsh-scripts`
|
||||
|
||||
The `dsh` launcher owns SDK project startup and configuration.
|
||||
|
||||
| Command | Behavior |
|
||||
|---|---|
|
||||
| `dsh start [target] [-- args…]` | Import a module target and invoke `main(bootContext)`, or boot `cordis.yml` when omitted; arguments after `--` are forwarded |
|
||||
| `dsh dev [target] [-- args…]` | Register TypeScript and local-workspace source resolution, then use the start path |
|
||||
| `dsh build [args…]` | Invoke the project's installed tsdown with the project arguments |
|
||||
| `dsh 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` 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.
|
||||
54
packages/sdk/scripts/package.json
Normal file
54
packages/sdk/scripts/package.json
Normal file
@@ -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": "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"
|
||||
}
|
||||
}
|
||||
70
packages/sdk/scripts/src/args.ts
Normal file
70
packages/sdk/scripts/src/args.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Commander adapter for the dsh subcommand surface.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/args
|
||||
*/
|
||||
|
||||
import { parseArgs as parseNodeArgs } from 'node:util'
|
||||
import { Command } from 'commander'
|
||||
|
||||
/** Commands implemented by the dsh launcher. */
|
||||
type DshCommand = 'start' | 'dev' | 'build' | 'config'
|
||||
|
||||
/** Parsed dsh invocation. */
|
||||
export interface DshArgs {
|
||||
command?: DshCommand
|
||||
target?: string
|
||||
forwarded: readonly string[]
|
||||
help: boolean
|
||||
}
|
||||
|
||||
/** Parse arbitrary project flags through Node's zero-schema argument parser. */
|
||||
export function parseSdkBootArgs(argv: readonly string[]): Record<string, string | boolean | undefined> {
|
||||
return parseNodeArgs({
|
||||
args: [...argv],
|
||||
strict: false,
|
||||
allowPositionals: true,
|
||||
allowNegative: true,
|
||||
}).values
|
||||
}
|
||||
|
||||
/** Parse one launcher invocation through real Commander subcommands. */
|
||||
export function parseDshArgs(argv: readonly string[]): DshArgs {
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
return { forwarded: [], help: true }
|
||||
}
|
||||
const separator = argv.indexOf('--')
|
||||
const launcherArgv = separator === -1 ? argv : argv.slice(0, separator)
|
||||
const passthrough = separator === -1 ? [] : argv.slice(separator + 1)
|
||||
let parsed: DshArgs | undefined
|
||||
const program = new Command()
|
||||
.name('dsh')
|
||||
.helpOption(false)
|
||||
.showHelpAfterError(false)
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
/* v8 ignore next -- the command wrapper renders the package-owned usage template */
|
||||
writeOut: () => {},
|
||||
/* v8 ignore next -- Commander errors are returned to the command wrapper */
|
||||
writeErr: () => {},
|
||||
})
|
||||
program.command('start [target]').helpOption(false).action((target?: string) => {
|
||||
parsed = { command: 'start', ...target ? { target } : {}, forwarded: [], help: false }
|
||||
})
|
||||
program.command('dev [target]').helpOption(false).action((target?: string) => {
|
||||
parsed = { command: 'dev', ...target ? { target } : {}, forwarded: [], help: false }
|
||||
})
|
||||
program.command('build [args...]').helpOption(false).allowUnknownOption(true).action((args: string[] = []) => {
|
||||
parsed = { command: 'build', forwarded: args, help: false }
|
||||
})
|
||||
program.command('config').helpOption(false).action(() => {
|
||||
parsed = { command: 'config', forwarded: [], help: false }
|
||||
})
|
||||
program.parse([...launcherArgv], { from: 'user' })
|
||||
/* v8 ignore next -- every registered Commander action above assigns parsed or Commander throws */
|
||||
if (!parsed) throw new Error('dsh command did not resolve')
|
||||
if (parsed.command === 'config' && passthrough.length > 0) {
|
||||
throw new Error('dsh config does not accept forwarded arguments')
|
||||
}
|
||||
return { ...parsed, forwarded: [...parsed.forwarded, ...passthrough] }
|
||||
}
|
||||
10
packages/sdk/scripts/src/bin.ts
Normal file
10
packages/sdk/scripts/src/bin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Self-executing dsh launcher.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/bin
|
||||
*/
|
||||
|
||||
import { runDshCommand } from './command.ts'
|
||||
|
||||
process.exitCode = await runDshCommand()
|
||||
79
packages/sdk/scripts/src/build.ts
Normal file
79
packages/sdk/scripts/src/build.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* User-owned tsdown configuration wrappers and child-process invocation.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/build
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { NodeCommandRunner, type CommandRunner } from '@deepseek-ai/dsh-helper'
|
||||
|
||||
function hasLocalPluginPackages(root: string): boolean {
|
||||
const directory = resolve(root, 'plugins')
|
||||
return existsSync(directory) && readdirSync(directory, { withFileTypes: true }).some(
|
||||
item => item.isDirectory() && existsSync(resolve(directory, item.name, 'package.json')),
|
||||
)
|
||||
}
|
||||
|
||||
function hasTsdownConfig(root: string): boolean {
|
||||
return ['tsdown.config.ts', 'tsdown.config.js', 'tsdown.config.mjs', 'tsdown.config.mts']
|
||||
.some(name => existsSync(resolve(root, name)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the developer's root config and append a separate workspace pass
|
||||
* when generated local plugin packages exist.
|
||||
* @param tsdownConfig - developer-owned root tsdown config.
|
||||
* @returns root tsdown config and optional local-plugin workspace pass.
|
||||
*/
|
||||
export function ProjectBuild(tsdownConfig: UserConfig): UserConfig[] {
|
||||
if (tsdownConfig.workspace !== undefined) {
|
||||
throw new Error('ProjectBuild owns workspace discovery; remove config.workspace')
|
||||
}
|
||||
const root = resolve(tsdownConfig.cwd ?? process.cwd())
|
||||
return hasLocalPluginPackages(root)
|
||||
? [{ ...tsdownConfig }, { workspace: { include: ['plugins/*'] } }]
|
||||
: [{ ...tsdownConfig }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve a local plugin package's developer-owned tsdown config.
|
||||
* @param tsdownConfig - developer-owned plugin tsdown config.
|
||||
* @returns validated tsdown config copy.
|
||||
*/
|
||||
export function PluginBuild(tsdownConfig: UserConfig): UserConfig {
|
||||
if (tsdownConfig.workspace !== undefined) throw new Error('PluginBuild does not accept nested workspace config')
|
||||
return { ...tsdownConfig }
|
||||
}
|
||||
|
||||
function resolveTsdownBin(cwd: string): string {
|
||||
const require = createRequire(resolve(cwd, 'package.json'))
|
||||
let manifestPath: string
|
||||
try {
|
||||
manifestPath = require.resolve('tsdown/package.json')
|
||||
} catch (error) {
|
||||
throw new Error(`dsh build requires tsdown in this project: ${String(error)}`)
|
||||
}
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { bin?: unknown }
|
||||
const bin = typeof manifest.bin === 'string'
|
||||
? manifest.bin
|
||||
: manifest.bin && typeof manifest.bin === 'object'
|
||||
? (manifest.bin as Record<string, unknown>).tsdown
|
||||
: undefined
|
||||
if (typeof bin !== 'string') throw new Error('installed tsdown package has no executable')
|
||||
return resolve(dirname(manifestPath), bin)
|
||||
}
|
||||
|
||||
/** Invoke the project's installed tsdown, forwarding all build arguments. */
|
||||
export async function runProjectBuild(
|
||||
args: readonly string[],
|
||||
cwd: string = process.cwd(),
|
||||
runner: CommandRunner = new NodeCommandRunner(),
|
||||
): Promise<void> {
|
||||
if (!hasTsdownConfig(cwd)) return
|
||||
const result = await runner.run(process.execPath, [resolveTsdownBin(cwd), ...args], resolve(cwd))
|
||||
if (result.signal) throw new Error(`tsdown was killed by ${result.signal}`)
|
||||
if (result.exitCode !== 0) throw new Error(`tsdown exited with code ${String(result.exitCode)}`)
|
||||
}
|
||||
58
packages/sdk/scripts/src/command.ts
Normal file
58
packages/sdk/scripts/src/command.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Internal dsh command composition used by the package bin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/command
|
||||
*/
|
||||
|
||||
import { parseDshArgs } from './args.ts'
|
||||
import { runProjectBuild } from './build.ts'
|
||||
import { runConfigCommand, type ConfigCommandContext } from './config.ts'
|
||||
import { runSDK } from './runtime.ts'
|
||||
import { DSH_TEMPLATES } from './templates/dsh-templates.ts'
|
||||
|
||||
/** Injectable process and command boundaries used by the dsh bin. */
|
||||
export interface DshCommandContext extends ConfigCommandContext {
|
||||
cwd: string
|
||||
stdin: NodeJS.ReadStream
|
||||
stdout: NodeJS.WriteStream
|
||||
stderr: NodeJS.WriteStream
|
||||
run?: typeof runSDK
|
||||
build?: typeof runProjectBuild
|
||||
config?: typeof runConfigCommand
|
||||
}
|
||||
|
||||
/** Run one parsed dsh command and return its process exit code. */
|
||||
export async function runDshCommand(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
context: DshCommandContext = {
|
||||
cwd: process.cwd(),
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
},
|
||||
): Promise<number> {
|
||||
try {
|
||||
const args = parseDshArgs(argv)
|
||||
if (args.help || !args.command) {
|
||||
context.stdout.write(DSH_TEMPLATES.usage.render({}))
|
||||
return 0
|
||||
}
|
||||
const run = context.run ?? runSDK
|
||||
const build = context.build ?? runProjectBuild
|
||||
const config = context.config ?? runConfigCommand
|
||||
switch (args.command) {
|
||||
case 'start': await run(args.target, { cwd: context.cwd, argv: args.forwarded }); break
|
||||
case 'dev': await run(args.target, { cwd: context.cwd, dev: true, argv: args.forwarded }); break
|
||||
case 'build': await build(args.forwarded, context.cwd); break
|
||||
case 'config': {
|
||||
const result = await config(context)
|
||||
if (result.installError) return 1
|
||||
break
|
||||
}
|
||||
}
|
||||
return 0
|
||||
} catch (error) {
|
||||
context.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
37
packages/sdk/scripts/src/config.ts
Normal file
37
packages/sdk/scripts/src/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* dsh config command composition.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/config
|
||||
*/
|
||||
|
||||
import {
|
||||
ClackPromptPort,
|
||||
SdkProject,
|
||||
createBuiltinRegistry,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { ConfigWorkflow, type ConfigWorkflowResult } from './config/config-workflow.ts'
|
||||
|
||||
/** Process stream slice required by dsh config. */
|
||||
export interface ConfigCommandContext {
|
||||
cwd: string
|
||||
stdin: NodeJS.ReadStream
|
||||
stdout: NodeJS.WriteStream
|
||||
port?: PromptPort
|
||||
install?: (project: SdkProject) => Promise<void>
|
||||
}
|
||||
|
||||
/** Open and interactively edit one existing SDK project. */
|
||||
export async function runConfigCommand(context: ConfigCommandContext): Promise<ConfigWorkflowResult> {
|
||||
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('dsh config requires an interactive TTY')
|
||||
}
|
||||
const project = await SdkProject.open(context.cwd)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
return new ConfigWorkflow(
|
||||
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
|
||||
context.port ?? new ClackPromptPort(context.stdin, context.stdout),
|
||||
context.stdout,
|
||||
context.install,
|
||||
).run(project, registry)
|
||||
}
|
||||
200
packages/sdk/scripts/src/config/config-workflow.ts
Normal file
200
packages/sdk/scripts/src/config/config-workflow.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Tree-shaped existing-project feature workflow and single Apply boundary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/config/config-workflow
|
||||
*/
|
||||
|
||||
import type { Writable } from 'node:stream'
|
||||
import {
|
||||
FeatureConfigurator,
|
||||
ConfirmQuestion,
|
||||
requireAnswer,
|
||||
type Feature,
|
||||
type FeatureInstallation,
|
||||
type FeatureRegistry,
|
||||
type FeatureSelection,
|
||||
type ChangeSet,
|
||||
type NestedMultiSelectValue,
|
||||
type ProjectCommitResult,
|
||||
type PromptPort,
|
||||
type SdkProject,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { DSH_TEMPLATES } from '../templates/dsh-templates.ts'
|
||||
|
||||
/** Config result, including an install failure that happened after commit. */
|
||||
export interface ConfigWorkflowResult {
|
||||
commit?: ProjectCommitResult<SdkProject>
|
||||
installError?: Error
|
||||
}
|
||||
|
||||
function featureTarget(feature: Feature): string {
|
||||
return `feature:${feature.id}`
|
||||
}
|
||||
|
||||
function pluginTarget(id: string): string {
|
||||
return `plugin:${id}`
|
||||
}
|
||||
|
||||
function sameOptions(left: readonly string[], right: readonly string[]): boolean {
|
||||
return [...left].sort().join('\0') === [...right].sort().join('\0')
|
||||
}
|
||||
|
||||
/** Reconcile one tree selection into domain commands, then review and commit once. */
|
||||
export class ConfigWorkflow {
|
||||
private readonly port: PromptPort
|
||||
private readonly output: Writable
|
||||
private readonly install: (project: SdkProject) => Promise<void>
|
||||
|
||||
/** Bind terminal prompts and descriptive output. */
|
||||
constructor(
|
||||
port: PromptPort,
|
||||
output: Writable = process.stdout,
|
||||
install: (project: SdkProject) => Promise<void> = project => project.profile.packageManager.install(project.root),
|
||||
) {
|
||||
this.port = port
|
||||
this.output = output
|
||||
this.install = install
|
||||
}
|
||||
|
||||
/** Select desired state, reconcile the working copy, review, and apply. */
|
||||
async run(project: SdkProject, registry: FeatureRegistry): Promise<ConfigWorkflowResult> {
|
||||
const edit = project.edit(registry)
|
||||
const configurator = new FeatureConfigurator(this.port)
|
||||
const features = registry.all().filter(feature => feature.isApplicable(project.profile))
|
||||
const inspections = new Map(edit.inspections().map(item => [item.id, item]))
|
||||
const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile))
|
||||
const desired = requireAnswer(await this.port.nestedMultiselect<string, string>({
|
||||
message: 'Configure the project',
|
||||
showChanges: true,
|
||||
options: [
|
||||
...features.map((feature) => {
|
||||
const installation = inspections.get(feature.id)
|
||||
/* v8 ignore next -- inspections() is built from this exact feature registry */
|
||||
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
|
||||
const inconsistent = installation.state === 'inconsistent'
|
||||
const selectedOptions = new Set(installation.options.length > 0
|
||||
? installation.options
|
||||
: feature.defaultOptions(project.profile))
|
||||
return {
|
||||
value: featureTarget(feature),
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || installation.state === 'enabled' || inconsistent,
|
||||
disabled: inconsistent,
|
||||
...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
|
||||
...feature.mode === 'single' ? {} : {
|
||||
choiceMode: feature.mode,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: selectedOptions.has(option.id),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}),
|
||||
...custom.map(entry => ({
|
||||
value: pluginTarget(entry.id),
|
||||
label: `${entry.name} [custom]`,
|
||||
default: !entry.disabled,
|
||||
})),
|
||||
],
|
||||
}))
|
||||
const desiredByTarget = new Map(desired.map(item => [item.value, item]))
|
||||
|
||||
for (const feature of features) {
|
||||
const installation = inspections.get(feature.id)
|
||||
/* v8 ignore next -- inspections() is built from this exact feature registry */
|
||||
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
|
||||
if (installation.state === 'inconsistent') continue
|
||||
const choice = desiredByTarget.get(featureTarget(feature))
|
||||
if (!choice && !feature.required) continue
|
||||
await this.enableOrConfigure(feature, installation, choice, project, edit, configurator)
|
||||
}
|
||||
|
||||
for (const feature of [...features].reverse()) {
|
||||
const installation = inspections.get(feature.id)
|
||||
/* v8 ignore next -- inspections() is built from this exact feature registry */
|
||||
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
|
||||
if (feature.required || installation.state !== 'enabled'
|
||||
|| desiredByTarget.has(featureTarget(feature))) continue
|
||||
edit.disableFeature(feature)
|
||||
}
|
||||
|
||||
for (const entry of custom) {
|
||||
const enabled = desiredByTarget.has(pluginTarget(entry.id))
|
||||
if (enabled === !entry.disabled) continue
|
||||
edit.setCustomPluginDisabled(entry.id, !enabled)
|
||||
}
|
||||
|
||||
const changes = edit.changes()
|
||||
if (changes.changedFiles.length === 0) {
|
||||
this.output.write('No changes.\n')
|
||||
return {}
|
||||
}
|
||||
this.renderReview(changes)
|
||||
const apply = requireAnswer(await new ConfirmQuestion({
|
||||
id: 'config.apply', message: 'Apply these changes?', initialValue: true,
|
||||
}).resolve(this.port))
|
||||
if (!apply) return {}
|
||||
const commit = await edit.commit()
|
||||
if (!commit.changes.npmDependenciesChanged) return { commit }
|
||||
try {
|
||||
await this.install(project)
|
||||
return { commit }
|
||||
} catch (error) {
|
||||
const installError = error instanceof Error ? error : new Error(String(error))
|
||||
const manager = project.profile.packageManager
|
||||
this.output.write(DSH_TEMPLATES.configInstallFailure.render({
|
||||
error: installError.message,
|
||||
packageManager: manager.name,
|
||||
installArgs: manager.installCommand().join(' '),
|
||||
}))
|
||||
return { commit, installError }
|
||||
}
|
||||
}
|
||||
|
||||
private async enableOrConfigure(
|
||||
feature: Feature,
|
||||
installation: FeatureInstallation,
|
||||
choice: NestedMultiSelectValue<string, string> | undefined,
|
||||
project: SdkProject,
|
||||
edit: ReturnType<SdkProject['edit']>,
|
||||
configurator: FeatureConfigurator,
|
||||
): Promise<void> {
|
||||
const options = choice?.choices.length
|
||||
? choice.choices
|
||||
: installation.options.length > 0
|
||||
? installation.options
|
||||
: feature.defaultOptions(project.profile)
|
||||
if (installation.state === 'absent') {
|
||||
const selection = await configurator.configure(feature, project.profile, undefined, options)
|
||||
edit.installFeature(feature, selection)
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- non-absent/non-inconsistent inspections always carry their normalized selection */
|
||||
if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`)
|
||||
if (!sameOptions(installation.options, options)) {
|
||||
const selection: FeatureSelection = await configurator.configure(
|
||||
feature,
|
||||
project.profile,
|
||||
installation.selection,
|
||||
options,
|
||||
)
|
||||
edit.configureFeature(feature, selection)
|
||||
}
|
||||
if (installation.state === 'disabled') edit.enableFeature(feature)
|
||||
}
|
||||
|
||||
private renderReview(changes: ChangeSet): void {
|
||||
const lines = [
|
||||
...changes.addedFeatures.map(id => `Install feature: ${id}`),
|
||||
...changes.enabledFeatures.map(id => `Enable feature: ${id}`),
|
||||
...changes.disabledFeatures.map(id => `Disable feature: ${id}`),
|
||||
...changes.configuredFeatures.map(id => `Configure feature: ${id}`),
|
||||
...changes.enabledPlugins.map(id => `Enable custom plugin: ${id}`),
|
||||
...changes.disabledPlugins.map(id => `Disable custom plugin: ${id}`),
|
||||
...changes.changedFiles.map(path => `Change file: ${path}`),
|
||||
]
|
||||
this.output.write(`${lines.join('\n')}\n`)
|
||||
}
|
||||
}
|
||||
7
packages/sdk/scripts/src/dev/tsdown-config.ts
Normal file
7
packages/sdk/scripts/src/dev/tsdown-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Generated-project tsdown config wrappers.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/dev/tsdown-config
|
||||
*/
|
||||
|
||||
export { PluginBuild, ProjectBuild } from '../build.ts'
|
||||
7
packages/sdk/scripts/src/index.ts
Normal file
7
packages/sdk/scripts/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Public DeepSeek Harness SDK runtime entry points.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts
|
||||
*/
|
||||
|
||||
export { runSDK, startSDK, type SdkBootContext } from './runtime.ts'
|
||||
27
packages/sdk/scripts/src/local-plugin-loader-hooks.ts
Normal file
27
packages/sdk/scripts/src/local-plugin-loader-hooks.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Node module customization hook for project-local plugin package names.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/local-plugin-loader-hooks
|
||||
*/
|
||||
|
||||
import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
|
||||
|
||||
interface HookData {
|
||||
mappings: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
let mappings: Readonly<Record<string, string>> = {}
|
||||
|
||||
/** Receive the package-name to source-URL map from the launcher thread. */
|
||||
export function initialize(data: HookData): void {
|
||||
mappings = { ...data.mappings }
|
||||
}
|
||||
|
||||
/** Resolve exact local workspace package names to their TypeScript entry source. */
|
||||
export async function resolve(
|
||||
specifier: string,
|
||||
context: ResolveHookContext,
|
||||
nextResolve: (specifier: string, context: ResolveHookContext) => Promise<ResolveFnOutput>,
|
||||
): Promise<ResolveFnOutput> {
|
||||
return nextResolve(mappings[specifier] ?? specifier, context)
|
||||
}
|
||||
137
packages/sdk/scripts/src/runtime.ts
Normal file
137
packages/sdk/scripts/src/runtime.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Shared start/dev runtime and project-local module resolution.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/runtime
|
||||
*/
|
||||
|
||||
import { register as registerHook } from 'node:module'
|
||||
import { access, readFile, readdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
import { parseSdkBootArgs } from './args.ts'
|
||||
|
||||
/** Options that distinguish dev boot from production boot. */
|
||||
interface BootProjectOptions {
|
||||
cwd?: string
|
||||
dev?: boolean
|
||||
argv?: readonly string[]
|
||||
}
|
||||
|
||||
/** Startup context passed to a generated project's exported `main()`. */
|
||||
export interface SdkBootContext {
|
||||
/** Developer arguments forwarded after the launcher's `--` separator. */
|
||||
readonly argv: readonly string[]
|
||||
/** SDK-recognized structured arguments parsed from {@link argv}. */
|
||||
readonly args: Record<string, string | boolean | undefined>
|
||||
/** Absolute project working directory selected by the launcher. */
|
||||
readonly cwd: string
|
||||
/** Whether the launcher is running the built or TypeScript development entry. */
|
||||
readonly mode: 'start' | 'dev'
|
||||
}
|
||||
|
||||
async function localPluginMappings(cwd: string): Promise<Record<string, string>> {
|
||||
const mappings: Record<string, string> = {}
|
||||
let directories
|
||||
try {
|
||||
directories = await readdir(resolve(cwd, 'plugins'), { withFileTypes: true })
|
||||
} catch (error) {
|
||||
/* v8 ignore else -- the other arm requires a filesystem permission/IO fault from readdir */
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return mappings
|
||||
/* v8 ignore next -- paired with the ignored defensive readdir-error arm above */
|
||||
throw error
|
||||
}
|
||||
for (const directory of directories) {
|
||||
if (!directory.isDirectory()) continue
|
||||
const root = resolve(cwd, 'plugins', directory.name)
|
||||
let manifest: { name?: unknown }
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')) as { name?: unknown }
|
||||
await access(resolve(root, 'src/index.ts'))
|
||||
} catch (error) {
|
||||
throw new Error(`cannot load local plugin metadata from ${root}: ${String(error)}`)
|
||||
}
|
||||
if (typeof manifest.name !== 'string' || manifest.name.length === 0) {
|
||||
throw new Error(`local plugin package has no name: ${root}`)
|
||||
}
|
||||
if (mappings[manifest.name]) throw new Error(`duplicate local plugin package name: ${manifest.name}`)
|
||||
mappings[manifest.name] = pathToFileURL(resolve(root, 'src/index.ts')).href
|
||||
}
|
||||
return mappings
|
||||
}
|
||||
|
||||
/** Register tsx and exact local-plugin source mappings for the current process. */
|
||||
async function registerDevRuntime(cwd: string = process.cwd()): Promise<void> {
|
||||
let registerTsx: typeof import('tsx/esm/api')['register']
|
||||
try {
|
||||
({ register: registerTsx } = await import('tsx/esm/api'))
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- tsx is a declared project NPM dependency; missing-package behavior is defensive */
|
||||
throw new Error(`dsh dev requires the project's tsx NPM dependency: ${String(error)}`)
|
||||
}
|
||||
registerTsx()
|
||||
const mappings = await localPluginMappings(resolve(cwd))
|
||||
const hook = new URL(
|
||||
/* v8 ignore next -- the .js arm is exercised by the built-bin smoke rather than source coverage */
|
||||
import.meta.url.endsWith('.ts')
|
||||
? './local-plugin-loader-hooks.ts'
|
||||
: './local-plugin-loader-hooks.js', import.meta.url)
|
||||
registerHook(hook, { data: { mappings } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot one cordis.yml after loading its sibling .env.
|
||||
* @param source - file path or file URL to cordis.yml.
|
||||
* @param options - working directory and development-runtime options.
|
||||
* @returns live Cordis context.
|
||||
*/
|
||||
export async function startSDK(
|
||||
source: string | URL = './cordis.yml',
|
||||
options: BootProjectOptions = {},
|
||||
): Promise<Context> {
|
||||
const cwd = resolve(options.cwd ?? process.cwd())
|
||||
if (options.dev) await registerDevRuntime(cwd)
|
||||
if (source instanceof URL && source.protocol !== 'file:') {
|
||||
throw new Error(`cordis.yml URL must use file:, got ${source.protocol}`)
|
||||
}
|
||||
const requested = source instanceof URL ? fileURLToPath(source) : source
|
||||
const absolute = resolveConfigPath(requested, undefined, cwd)
|
||||
loadEnv('dsh', dirname(absolute))
|
||||
installFailLoud('dsh')
|
||||
return boot('dsh', absolute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Import and invoke a module target's main(), or directly boot cordis.yml.
|
||||
* @param target - module path relative to the project, or absent for cordis.yml.
|
||||
* @param options - working directory and development-runtime options.
|
||||
* @returns target main result or live Cordis context.
|
||||
*/
|
||||
export async function runSDK(
|
||||
target: string | undefined,
|
||||
options: BootProjectOptions = {},
|
||||
): Promise<unknown> {
|
||||
/* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */
|
||||
const cwd = resolve(options.cwd ?? process.cwd())
|
||||
if (options.dev) await registerDevRuntime(cwd)
|
||||
if (!target) return startSDK('./cordis.yml', { cwd })
|
||||
const absolute = resolve(cwd, target)
|
||||
try {
|
||||
await access(absolute)
|
||||
} catch (error) {
|
||||
const hint = options.dev ? '' : ' Run dsh build first if this is a TypeScript project.'
|
||||
throw new Error(`cannot start missing target ${target}.${hint} ${String(error)}`)
|
||||
}
|
||||
const module = await import(pathToFileURL(absolute).href) as { main?: (context: SdkBootContext) => unknown }
|
||||
if (typeof module.main !== 'function') {
|
||||
throw new Error(`dsh target ${target} must export function main()`)
|
||||
}
|
||||
const argv = [...options.argv ?? []]
|
||||
return module.main({
|
||||
argv,
|
||||
args: parseSdkBootArgs(argv),
|
||||
cwd,
|
||||
mode: options.dev ? 'dev' : 'start',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Changes were committed, but install failed: {{error}}
|
||||
Retry: {{packageManager}} {{installArgs}}
|
||||
7
packages/sdk/scripts/src/templates/assets/usage.txt.tpl
Normal file
7
packages/sdk/scripts/src/templates/assets/usage.txt.tpl
Normal file
@@ -0,0 +1,7 @@
|
||||
Usage: dsh <command> [options]
|
||||
|
||||
Commands:
|
||||
start [target] [-- args...] Import a built module, or boot cordis.yml
|
||||
dev [target] [-- args...] Start with TypeScript and local-plugin source resolution
|
||||
build [args...] Run the project's installed tsdown
|
||||
config Interactively edit project features
|
||||
21
packages/sdk/scripts/src/templates/dsh-templates.ts
Normal file
21
packages/sdk/scripts/src/templates/dsh-templates.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Package-owned terminal templates for the dsh launcher.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/templates/dsh-templates
|
||||
*/
|
||||
|
||||
import { TextTemplate, type PackageManagerName } from '@deepseek-ai/dsh-helper'
|
||||
|
||||
interface ConfigInstallFailureTemplateModel {
|
||||
error: string
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
}
|
||||
|
||||
/** Compiled dsh terminal templates. */
|
||||
export const DSH_TEMPLATES = {
|
||||
usage: TextTemplate.fromFile<Record<string, never>>(new URL('./assets/usage.txt.tpl', import.meta.url)),
|
||||
configInstallFailure: TextTemplate.fromFile<ConfigInstallFailureTemplateModel>(
|
||||
new URL('./assets/config-install-failure.txt.tpl', import.meta.url),
|
||||
),
|
||||
} as const
|
||||
303
packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap
Normal file
303
packages/sdk/scripts/tests/__snapshots__/config.snapshot.ts.snap
Normal file
@@ -0,0 +1,303 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`dsh config terminal contract > pins the feature tree and Review & Apply output 1`] = `
|
||||
{
|
||||
"committed": {
|
||||
"addedFeatures": [
|
||||
"todo",
|
||||
],
|
||||
"addedPlugins": [],
|
||||
"changedFiles": [
|
||||
"cordis.yml",
|
||||
"package.json",
|
||||
],
|
||||
"configuredFeatures": [],
|
||||
"disabledFeatures": [],
|
||||
"disabledPlugins": [],
|
||||
"enabledFeatures": [],
|
||||
"enabledPlugins": [],
|
||||
"npmDependenciesChanged": true,
|
||||
},
|
||||
"installs": 1,
|
||||
"review": "Install feature: todo
|
||||
Change file: cordis.yml
|
||||
Change file: package.json
|
||||
",
|
||||
"transcript": [
|
||||
{
|
||||
"kind": "nested-multiselect",
|
||||
"message": "Configure the project",
|
||||
"options": [
|
||||
{
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": true,
|
||||
"label": "DeepSeek",
|
||||
"value": "deepseek",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Custom endpoint (pi-ai)",
|
||||
"value": "custom",
|
||||
},
|
||||
],
|
||||
"default": true,
|
||||
"disabled": false,
|
||||
"label": "Model provider",
|
||||
"required": true,
|
||||
"value": "feature:provider",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": true,
|
||||
"disabled": false,
|
||||
"label": "Agent runtime spine",
|
||||
"required": true,
|
||||
"value": "feature:spine",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": true,
|
||||
"label": "Local executor",
|
||||
"value": "local",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Sandboxed executor",
|
||||
"value": "sandbox",
|
||||
},
|
||||
],
|
||||
"default": true,
|
||||
"disabled": false,
|
||||
"label": "Command execution",
|
||||
"required": true,
|
||||
"value": "feature:bash",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": false,
|
||||
"label": "ACP server",
|
||||
"value": "acp",
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
"label": "Terminal REPL",
|
||||
"value": "stdio",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Embedded context",
|
||||
"value": "embed",
|
||||
},
|
||||
],
|
||||
"default": true,
|
||||
"disabled": false,
|
||||
"label": "Run interface",
|
||||
"required": true,
|
||||
"value": "feature:app",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": true,
|
||||
"label": "JSONL files",
|
||||
"value": "jsonl",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "SQLite database",
|
||||
"value": "sqlite",
|
||||
},
|
||||
],
|
||||
"default": true,
|
||||
"disabled": false,
|
||||
"label": "Durable session storage",
|
||||
"required": true,
|
||||
"value": "feature:persistence",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Hot-module reload",
|
||||
"required": false,
|
||||
"value": "feature:hmr",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Read, write, and edit local files",
|
||||
"required": false,
|
||||
"value": "feature:fs",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Model-facing task tracking",
|
||||
"required": false,
|
||||
"value": "feature:todo",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Local skill discovery",
|
||||
"required": false,
|
||||
"value": "feature:skill",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": "exclusive",
|
||||
"choices": [
|
||||
{
|
||||
"default": true,
|
||||
"label": "DeepSeek search",
|
||||
"value": "deepseek",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Exa search",
|
||||
"value": "exa",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Perplexity search",
|
||||
"value": "perplexity",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Fetch only",
|
||||
"value": "fetch-only",
|
||||
},
|
||||
],
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Web search and fetch tools",
|
||||
"required": false,
|
||||
"value": "feature:web",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": "multiple",
|
||||
"choices": [
|
||||
{
|
||||
"default": true,
|
||||
"label": "Fresh child agent",
|
||||
"value": "spawn",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Fork parent history",
|
||||
"value": "fork",
|
||||
},
|
||||
],
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Delegate work to child agents",
|
||||
"required": false,
|
||||
"value": "feature:subagent",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Scripted multi-agent workflows",
|
||||
"required": false,
|
||||
"value": "feature:workflow",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Automatic context compaction",
|
||||
"required": false,
|
||||
"value": "feature:compact",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": "multiple",
|
||||
"choices": [
|
||||
{
|
||||
"default": true,
|
||||
"label": "Claude Code hooks",
|
||||
"value": "claude",
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"label": "Codex hooks",
|
||||
"value": "codex",
|
||||
},
|
||||
],
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Run Claude Code or Codex hooks",
|
||||
"required": false,
|
||||
"value": "feature:hooks",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Loop-hygiene reminders",
|
||||
"required": false,
|
||||
"value": "feature:guard",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Tool timeout policy",
|
||||
"required": false,
|
||||
"value": "feature:timeout-policy",
|
||||
"warning": undefined,
|
||||
},
|
||||
{
|
||||
"choiceMode": undefined,
|
||||
"choices": undefined,
|
||||
"default": false,
|
||||
"disabled": false,
|
||||
"label": "Ask the user from the model loop",
|
||||
"required": false,
|
||||
"value": "feature:ask-user",
|
||||
"warning": undefined,
|
||||
},
|
||||
],
|
||||
"showChanges": true,
|
||||
},
|
||||
{
|
||||
"initialValue": true,
|
||||
"kind": "confirm",
|
||||
"message": "Apply these changes?",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
128
packages/sdk/scripts/tests/config.snapshot.ts
Normal file
128
packages/sdk/scripts/tests/config.snapshot.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NpmPackageManager,
|
||||
SdkProject,
|
||||
featureId,
|
||||
createBuiltinRegistry,
|
||||
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 { ConfigWorkflow } from '../src/config/config-workflow.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 })
|
||||
}
|
||||
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 => ({ value: option.value, label: option.label })),
|
||||
})
|
||||
}
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
return this.answer({ kind: 'multiselect', message: request.message })
|
||||
}
|
||||
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,
|
||||
showChanges: request.showChanges,
|
||||
options: request.options.map(option => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
required: option.required,
|
||||
default: option.default,
|
||||
disabled: option.disabled,
|
||||
warning: option.warning,
|
||||
choiceMode: option.choiceMode,
|
||||
choices: option.choices?.map(choice => ({
|
||||
value: choice.value,
|
||||
label: choice.label,
|
||||
default: choice.default,
|
||||
})),
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const temporary: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function baseProject(): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-config-snapshot-'))
|
||||
temporary.push(root)
|
||||
const request = {
|
||||
name: 'snapshot-agent',
|
||||
description: 'snapshot',
|
||||
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: ['stdio'] },
|
||||
{ id: featureId('persistence'), options: ['jsonl'] },
|
||||
],
|
||||
localPlugins: [],
|
||||
}
|
||||
const project = SdkProject.create(root, request)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of request.features) edit.installFeature(registry.get(item.id), item)
|
||||
return (await edit.commit()).project
|
||||
}
|
||||
|
||||
describe('dsh config terminal contract', () => {
|
||||
it('pins the feature tree and Review & Apply output', async () => {
|
||||
const project = await baseProject()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const port = new RecordingPort([
|
||||
[{ value: 'feature:todo', choices: [] }],
|
||||
true,
|
||||
])
|
||||
let output = ''
|
||||
const stream = new Writable({ write(chunk, _encoding, callback) { output += String(chunk); callback() } })
|
||||
let installs = 0
|
||||
const result = await new ConfigWorkflow(port, stream, async () => { installs += 1 }).run(project, registry)
|
||||
expect({
|
||||
transcript: port.transcript,
|
||||
review: output,
|
||||
installs,
|
||||
committed: result.commit?.changes,
|
||||
}).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
483
packages/sdk/scripts/tests/scripts.spec.ts
Normal file
483
packages/sdk/scripts/tests/scripts.spec.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LocalPluginBlueprint,
|
||||
NpmPackageManager,
|
||||
SdkProject,
|
||||
featureId,
|
||||
createBuiltinRegistry,
|
||||
type CommandRunner,
|
||||
type NestedMultiSelectValue,
|
||||
type ProjectCreationRequest,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import type {
|
||||
ConfirmPromptRequest,
|
||||
MultiSelectPromptRequest,
|
||||
NestedMultiSelectRequest,
|
||||
PromptOutcome,
|
||||
SecretPromptRequest,
|
||||
SelectPromptRequest,
|
||||
TextPromptRequest,
|
||||
} from '../../helper/src/questions/prompt-port.ts'
|
||||
import { runSDK, startSDK } from '@deepseek-ai/dsh-scripts'
|
||||
import { parseDshArgs, parseSdkBootArgs } from '../src/args.ts'
|
||||
import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts'
|
||||
import { runDshCommand, type DshCommandContext } from '../src/command.ts'
|
||||
import { runConfigCommand } from '../src/config.ts'
|
||||
import { ConfigWorkflow } from '../src/config/config-workflow.ts'
|
||||
import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
class QueuePort implements PromptPort {
|
||||
readonly #answers: unknown[]
|
||||
constructor(answers: unknown[]) { this.#answers = [...answers] }
|
||||
next<T>(): Promise<PromptOutcome<T>> {
|
||||
return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T })
|
||||
}
|
||||
text(_request: TextPromptRequest): Promise<PromptOutcome<string>> { return this.next() }
|
||||
secret(_request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.next() }
|
||||
select<T>(_request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.next() }
|
||||
multiselect<T>(_request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> { return this.next() }
|
||||
confirm(_request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.next() }
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
_request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> { return this.next() }
|
||||
}
|
||||
|
||||
function outputBuffer(): { stream: Writable; read: () => string } {
|
||||
let text = ''
|
||||
return {
|
||||
stream: new Writable({ write(chunk, _encoding, callback) { text += String(chunk); callback() } }),
|
||||
read: () => text,
|
||||
}
|
||||
}
|
||||
|
||||
function commandContext(cwd: string): DshCommandContext & { readStdout: () => string; readStderr: () => string } {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const stdin = 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, stdout: output, stderr: error,
|
||||
readStdout: () => stdout,
|
||||
readStderr: () => stderr,
|
||||
}
|
||||
}
|
||||
|
||||
function creation(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
name: 'config-agent',
|
||||
description: 'config test',
|
||||
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'] },
|
||||
...extra,
|
||||
],
|
||||
localPlugins,
|
||||
}
|
||||
}
|
||||
|
||||
async function committedProject(
|
||||
extra: ProjectCreationRequest['features'] = [],
|
||||
localPlugins: readonly LocalPluginBlueprint[] = [],
|
||||
): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-config-workflow-'))
|
||||
temporary.push(root)
|
||||
const request = creation(extra, localPlugins)
|
||||
const project = SdkProject.create(root, request)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of request.features) edit.installFeature(registry.get(item.id), item)
|
||||
for (const plugin of localPlugins) edit.addPlugin(plugin)
|
||||
return (await edit.commit()).project
|
||||
}
|
||||
|
||||
describe('Commander launcher arguments', () => {
|
||||
it('parses real subcommands and forwards arbitrary build options', () => {
|
||||
expect(parseDshArgs([])).toMatchObject({ help: true })
|
||||
expect(parseDshArgs(['start', 'index.js'])).toMatchObject({ command: 'start', target: 'index.js' })
|
||||
expect(parseDshArgs(['dev'])).toEqual({ command: 'dev', forwarded: [], help: false })
|
||||
expect(parseDshArgs(['build', '--watch', '--minify'])).toMatchObject({
|
||||
command: 'build', forwarded: ['--watch', '--minify'],
|
||||
})
|
||||
expect(parseDshArgs(['start', 'index.js', '--', '--resume', 'session-1'])).toMatchObject({
|
||||
command: 'start', target: 'index.js', forwarded: ['--resume', 'session-1'],
|
||||
})
|
||||
expect(parseDshArgs(['config'])).toMatchObject({ command: 'config' })
|
||||
expect(parseDshArgs(['start'])).toEqual({ command: 'start', forwarded: [], help: false })
|
||||
expect(parseDshArgs(['dev', 'index.ts'])).toMatchObject({ command: 'dev', target: 'index.ts' })
|
||||
expect(parseDshArgs(['-h'])).toMatchObject({ help: true })
|
||||
expect(() => parseDshArgs(['unknown'])).toThrow()
|
||||
expect(() => parseDshArgs(['config', 'extra'])).toThrow()
|
||||
expect(() => parseDshArgs(['config', '--', 'extra'])).toThrow('does not accept forwarded')
|
||||
expect(parseSdkBootArgs([
|
||||
'--model=mock', '--resume=session-1', '--custom=value', '--verbose', '--no-cache', '--max-depth=-1',
|
||||
])).toEqual({
|
||||
model: 'mock', resume: 'session-1', custom: 'value', verbose: true, cache: false, 'max-depth': '-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('dispatches every command and maps failures to exit codes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-command-'))
|
||||
temporary.push(root)
|
||||
const context = commandContext(root)
|
||||
const calls: unknown[] = []
|
||||
context.run = async (target, options) => { calls.push(['run', target, options]); return undefined }
|
||||
context.build = async (args, cwd) => { calls.push(['build', args, cwd]) }
|
||||
context.config = async () => { calls.push(['config']); return {} }
|
||||
await expect(runDshCommand(['start', 'index.js', '--', '--resume', 'session-1'], context)).resolves.toBe(0)
|
||||
await expect(runDshCommand(['dev', 'index.ts'], context)).resolves.toBe(0)
|
||||
await expect(runDshCommand(['build', '--watch'], context)).resolves.toBe(0)
|
||||
await expect(runDshCommand(['config'], context)).resolves.toBe(0)
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(calls[0]).toEqual(['run', 'index.js', { cwd: root, argv: ['--resume', 'session-1'] }])
|
||||
expect(calls[1]).toEqual(['run', 'index.ts', { cwd: root, dev: true, argv: [] }])
|
||||
context.config = async () => ({ installError: new Error('offline') })
|
||||
await expect(runDshCommand(['config'], context)).resolves.toBe(1)
|
||||
context.config = async () => { throw 'broken' }
|
||||
await expect(runDshCommand(['config'], context)).resolves.toBe(1)
|
||||
expect(context.readStderr()).toContain('broken')
|
||||
await expect(runDshCommand(['unknown'], context)).resolves.toBe(1)
|
||||
await expect(runDshCommand([], context)).resolves.toBe(0)
|
||||
expect(context.readStdout()).toContain('Usage: dsh')
|
||||
|
||||
const defaults = commandContext(root)
|
||||
await writeFile(join(root, 'main.mjs'), 'export function main() { return "ok" }\n')
|
||||
await expect(runDshCommand(['start', 'main.mjs'], defaults)).resolves.toBe(0)
|
||||
await expect(runDshCommand(['build'], defaults)).resolves.toBe(0)
|
||||
defaults.port = new QueuePort([[]])
|
||||
await expect(runDshCommand(['config'], defaults)).resolves.toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('build profiles and invocation', () => {
|
||||
it('discovers root and plugin targets and creates independent profiles', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-build-profile-'))
|
||||
temporary.push(root)
|
||||
await mkdir(join(root, 'plugins', 'one', 'src'), { recursive: true })
|
||||
await writeFile(join(root, 'index.ts'), 'export {}\n')
|
||||
await writeFile(join(root, 'plugins', 'one', 'package.json'), '{"name":"one"}\n')
|
||||
await writeFile(join(root, 'plugins', 'one', 'src', 'index.ts'), 'export {}\n')
|
||||
expect(ProjectBuild({ cwd: root, entry: ['index.ts'] })).toEqual([
|
||||
{ cwd: root, entry: ['index.ts'] },
|
||||
{ workspace: { include: ['plugins/*'] } },
|
||||
])
|
||||
expect(PluginBuild({ entry: ['src/index.ts'], dts: true })).toEqual({ entry: ['src/index.ts'], dts: true })
|
||||
expect(() => ProjectBuild({ workspace: true })).toThrow('owns workspace discovery')
|
||||
expect(() => PluginBuild({ workspace: true })).toThrow('does not accept nested workspace')
|
||||
expect(ProjectBuild({ cwd: join(root, 'empty'), entry: ['index.ts'] })).toEqual([
|
||||
{ cwd: join(root, 'empty'), entry: ['index.ts'] },
|
||||
])
|
||||
expect(ProjectBuild({ entry: ['index.ts'] })[0]).toMatchObject({ entry: ['index.ts'] })
|
||||
})
|
||||
|
||||
it('runs the project-installed tsdown and reports child failure', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-build-run-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), '{"type":"module"}\n')
|
||||
await writeFile(join(root, 'index.ts'), 'export {}\n')
|
||||
await writeFile(join(root, 'tsdown.config.ts'), 'export default {}\n')
|
||||
await mkdir(join(root, 'node_modules'), { recursive: true })
|
||||
const manifest = fileURLToPath(import.meta.resolve('tsdown/package.json'))
|
||||
await symlink(dirname(manifest), join(root, 'node_modules', 'tsdown'))
|
||||
const calls: string[][] = []
|
||||
const runner: CommandRunner = {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
return { exitCode: 0, signal: null }
|
||||
},
|
||||
}
|
||||
await runProjectBuild(['--watch'], root, runner)
|
||||
expect(calls[0]?.[0]).toBe(process.execPath)
|
||||
expect(calls[0]?.at(-1)).toBe('--watch')
|
||||
const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
|
||||
await expect(runProjectBuild([], root, failed)).rejects.toThrow('exited with code 2')
|
||||
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
|
||||
await expect(runProjectBuild([], root, killed)).rejects.toThrow('killed by SIGTERM')
|
||||
})
|
||||
|
||||
it('reports missing and malformed project tsdown executables', async () => {
|
||||
const missing = await mkdtemp(join(tmpdir(), 'dsh-build-missing-'))
|
||||
temporary.push(missing)
|
||||
await writeFile(join(missing, 'package.json'), '{"type":"module"}')
|
||||
await writeFile(join(missing, 'tsdown.config.ts'), 'export default {}\n')
|
||||
await expect(runProjectBuild([], missing)).rejects.toThrow('requires tsdown')
|
||||
const malformed = await mkdtemp(join(tmpdir(), 'dsh-build-malformed-'))
|
||||
temporary.push(malformed)
|
||||
await writeFile(join(malformed, 'package.json'), '{"type":"module"}')
|
||||
await writeFile(join(malformed, 'tsdown.config.ts'), 'export default {}\n')
|
||||
await mkdir(join(malformed, 'node_modules', 'tsdown'), { recursive: true })
|
||||
await writeFile(join(malformed, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({
|
||||
name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, bin: {},
|
||||
}))
|
||||
await expect(runProjectBuild([], malformed)).rejects.toThrow('has no executable')
|
||||
await writeFile(join(malformed, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({
|
||||
name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' },
|
||||
}))
|
||||
await expect(runProjectBuild([], malformed)).rejects.toThrow('has no executable')
|
||||
const stringBin = await mkdtemp(join(tmpdir(), 'dsh-build-string-bin-'))
|
||||
temporary.push(stringBin)
|
||||
await writeFile(join(stringBin, 'package.json'), '{"type":"module"}')
|
||||
await writeFile(join(stringBin, 'tsdown.config.js'), 'export default {}\n')
|
||||
await mkdir(join(stringBin, 'node_modules', 'tsdown'), { recursive: true })
|
||||
await writeFile(join(stringBin, 'node_modules', 'tsdown', 'package.json'), JSON.stringify({
|
||||
name: 'tsdown', version: '0.0.0', exports: { './package.json': './package.json' }, bin: 'cli.js',
|
||||
}))
|
||||
await writeFile(join(stringBin, 'node_modules', 'tsdown', 'cli.js'), '')
|
||||
let command = ''
|
||||
await runProjectBuild([], stringBin, {
|
||||
run: async (_node, args) => { command = args[0] ?? ''; return { exitCode: 0, signal: null } },
|
||||
})
|
||||
expect(command).toContain('cli.js')
|
||||
})
|
||||
|
||||
it('returns a no-op for a project with no build targets and hints on a missing start target', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-no-build-'))
|
||||
temporary.push(root)
|
||||
let called = false
|
||||
await runProjectBuild([], root, { run: async () => { called = true; return { exitCode: 0, signal: null } } })
|
||||
expect(called).toBe(false)
|
||||
await expect(runSDK('index.js', { cwd: root })).rejects.toThrow('Run dsh build first')
|
||||
})
|
||||
|
||||
it('invokes the target module main export and rejects passive modules', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-module-main-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'main.mjs'), 'export function main(context) { return context }\n')
|
||||
await writeFile(join(root, 'passive.mjs'), 'export const value = 1\n')
|
||||
await expect(runSDK('main.mjs', {
|
||||
cwd: root,
|
||||
argv: ['--model=mock', '--resume=session-1', 'custom'],
|
||||
})).resolves.toEqual({
|
||||
argv: ['--model=mock', '--resume=session-1', 'custom'],
|
||||
args: { model: 'mock', resume: 'session-1' }, cwd: root, mode: 'start',
|
||||
})
|
||||
await expect(runSDK('passive.mjs', { cwd: root })).rejects.toThrow('must export function main()')
|
||||
})
|
||||
|
||||
it('boots empty Cordis configs and delegates targetless runs', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-start-sdk-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'cordis.yml'), '[]\n')
|
||||
const byUrl = await startSDK(pathToFileURL(join(root, 'cordis.yml')))
|
||||
await byUrl.fiber.dispose()
|
||||
const byRun = await runSDK(undefined, { cwd: root }) as import('cordis').Context
|
||||
await byRun.fiber.dispose()
|
||||
const dev = await startSDK('./cordis.yml', { cwd: root, dev: true })
|
||||
await dev.fiber.dispose()
|
||||
await expect(startSDK(new URL('https://example.invalid/cordis.yml'), { cwd: root })).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('validates local plugin metadata in dev mode', async () => {
|
||||
const malformed = await mkdtemp(join(tmpdir(), 'dsh-dev-malformed-'))
|
||||
temporary.push(malformed)
|
||||
await mkdir(join(malformed, 'plugins', 'bad'), { recursive: true })
|
||||
await expect(runSDK('missing.ts', { cwd: malformed, dev: true })).rejects.toThrow('cannot load local plugin metadata')
|
||||
const absent = await mkdtemp(join(tmpdir(), 'dsh-dev-absent-'))
|
||||
temporary.push(absent)
|
||||
await expect(runSDK('missing.ts', { cwd: absent, dev: true })).rejects.toThrow('cannot start missing target')
|
||||
|
||||
const unnamed = await mkdtemp(join(tmpdir(), 'dsh-dev-unnamed-'))
|
||||
temporary.push(unnamed)
|
||||
await mkdir(join(unnamed, 'plugins', 'bad', 'src'), { recursive: true })
|
||||
await writeFile(join(unnamed, 'plugins', 'bad', 'package.json'), '{}')
|
||||
await writeFile(join(unnamed, 'plugins', 'bad', 'src/index.ts'), 'export {}\n')
|
||||
await expect(runSDK('missing.ts', { cwd: unnamed, dev: true })).rejects.toThrow('has no name')
|
||||
|
||||
const duplicate = await mkdtemp(join(tmpdir(), 'dsh-dev-duplicate-'))
|
||||
temporary.push(duplicate)
|
||||
for (const name of ['one', 'two']) {
|
||||
await mkdir(join(duplicate, 'plugins', name, 'src'), { recursive: true })
|
||||
await writeFile(join(duplicate, 'plugins', name, 'package.json'), '{"name":"same"}')
|
||||
await writeFile(join(duplicate, 'plugins', name, 'src/index.ts'), 'export {}\n')
|
||||
}
|
||||
await expect(runSDK('missing.ts', { cwd: duplicate, dev: true })).rejects.toThrow('duplicate local plugin')
|
||||
|
||||
const valid = await mkdtemp(join(tmpdir(), 'dsh-dev-valid-'))
|
||||
temporary.push(valid)
|
||||
await mkdir(join(valid, 'plugins', 'one', 'src'), { recursive: true })
|
||||
await writeFile(join(valid, 'plugins', 'README.md'), 'skip\n')
|
||||
await writeFile(join(valid, 'plugins', 'one', 'package.json'), '{"name":"local"}')
|
||||
await writeFile(join(valid, 'plugins', 'one', 'src/index.ts'), 'export {}\n')
|
||||
await writeFile(join(valid, 'main.ts'), 'export function main() { return "dev" }\n')
|
||||
await expect(runSDK('main.ts', { cwd: valid, dev: true })).resolves.toBe('dev')
|
||||
await expect(runSDK('missing.ts', { cwd: valid, dev: true })).rejects.toThrow('cannot start missing target')
|
||||
})
|
||||
|
||||
it('maps only exact local package names through the loader hook', async () => {
|
||||
initialize({ mappings: { local: 'file:///tmp/local.ts' } })
|
||||
const next = async (specifier: string) => ({ url: specifier, format: 'module' as const })
|
||||
const context: import('node:module').ResolveHookContext = {
|
||||
conditions: [], importAttributes: {}, parentURL: undefined,
|
||||
}
|
||||
await expect(resolveLocalPlugin('local', context, next)).resolves.toMatchObject({ url: 'file:///tmp/local.ts' })
|
||||
await expect(resolveLocalPlugin('other', context, next)).resolves.toMatchObject({ url: 'other' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigWorkflow', () => {
|
||||
it('opens a project through the config command prompt seam', async () => {
|
||||
const project = await committedProject()
|
||||
const context = commandContext(project.root)
|
||||
context.port = new QueuePort([[]])
|
||||
context.install = async () => { throw new Error('install should not run') }
|
||||
await expect(runConfigCommand(context)).resolves.toEqual({})
|
||||
delete context.port
|
||||
delete context.install
|
||||
context.stdin.isTTY = false
|
||||
await expect(runConfigCommand(context)).rejects.toThrow('interactive TTY')
|
||||
context.stdin.isTTY = true
|
||||
context.stdout.isTTY = false
|
||||
await expect(runConfigCommand(context)).rejects.toThrow('interactive TTY')
|
||||
})
|
||||
it('accumulates a disable and commits only after Review & Apply', async () => {
|
||||
const project = await committedProject([{ id: featureId('todo'), options: ['default'] }])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const output = outputBuffer()
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[], true,
|
||||
]), output.stream, async () => { throw new Error('install should not run') })
|
||||
const result = await workflow.run(project, registry)
|
||||
expect(result.commit?.project.cordis.entry('tool-todo')?.disabled).toBe(true)
|
||||
expect(output.read()).toContain('Disable feature: todo')
|
||||
})
|
||||
|
||||
it('installs once after NPM dependency changes and keeps committed files on install failure', async () => {
|
||||
const project = await committedProject()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const output = outputBuffer()
|
||||
let installs = 0
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'feature:todo', choices: [] }], true,
|
||||
]), output.stream, async () => {
|
||||
installs += 1
|
||||
throw new Error('offline')
|
||||
})
|
||||
const result = await workflow.run(project, registry)
|
||||
expect(installs).toBe(1)
|
||||
expect(result.installError?.message).toBe('offline')
|
||||
expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined()
|
||||
expect(output.read()).toContain('Changes were committed, but install failed')
|
||||
})
|
||||
|
||||
it('cancels apply and enables a disabled feature without reinstalling', async () => {
|
||||
const project = await committedProject([{ id: featureId('todo'), options: ['default'] }])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const cancelled = await new ConfigWorkflow(new QueuePort([[], false]), outputBuffer().stream).run(project, registry)
|
||||
expect(cancelled).toEqual({})
|
||||
const disable = project.edit(registry)
|
||||
disable.disableFeature(registry.get(featureId('todo')))
|
||||
const disabled = (await disable.commit()).project
|
||||
let installs = 0
|
||||
const enabled = await new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'feature:todo', choices: [] }], true,
|
||||
]), outputBuffer().stream, async () => { installs += 1 }).run(disabled, createBuiltinRegistry(disabled.profile))
|
||||
expect(enabled.commit?.project.cordis.entry('tool-todo')?.disabled).toBeUndefined()
|
||||
expect(installs).toBe(0)
|
||||
})
|
||||
|
||||
it('toggles custom Cordis config entries without changing NPM dependencies', async () => {
|
||||
const project = await committedProject([], [new LocalPluginBlueprint('sample', 'plugin')])
|
||||
await expect(new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'plugin:sample', choices: [] }],
|
||||
]), outputBuffer().stream).run(project, createBuiltinRegistry(project.profile))).resolves.toEqual({})
|
||||
const output = outputBuffer()
|
||||
const disabled = await new ConfigWorkflow(new QueuePort([[], true]), output.stream).run(
|
||||
project, createBuiltinRegistry(project.profile),
|
||||
)
|
||||
expect(disabled.commit?.project.cordis.entry('sample')?.disabled).toBe(true)
|
||||
expect(output.read()).toContain('Disable custom plugin: sample')
|
||||
const next = disabled.commit?.project
|
||||
if (!next) throw new Error('custom toggle did not commit')
|
||||
const enabled = await new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'plugin:sample', choices: [] }], true,
|
||||
]), outputBuffer().stream).run(next, createBuiltinRegistry(next.profile))
|
||||
expect(enabled.commit?.project.cordis.entry('sample')?.disabled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows inconsistent features as diagnostic-only rows', async () => {
|
||||
const complete = await committedProject()
|
||||
await writeFile(join(complete.root, 'cordis.yml'), `${await readFile(join(complete.root, 'cordis.yml'), 'utf8')}- id: web-search-exa
|
||||
name: '@deepseek-ai/dsh-web-search-exa'
|
||||
`)
|
||||
const project = await SdkProject.open(complete.root)
|
||||
const port = new QueuePort([[]])
|
||||
await expect(new ConfigWorkflow(port, outputBuffer().stream).run(project, createBuiltinRegistry(project.profile)))
|
||||
.resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('uses the default installer and normalizes non-Error install failures', async () => {
|
||||
const project = await committedProject()
|
||||
const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
|
||||
await new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'feature:todo', choices: [] }], true,
|
||||
])).run(project, createBuiltinRegistry(project.profile))
|
||||
expect(install).toHaveBeenCalledOnce()
|
||||
install.mockRestore()
|
||||
const next = await committedProject()
|
||||
const failed = await new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'feature:todo', choices: [] }], true,
|
||||
]), outputBuffer().stream, async () => { throw 'offline-string' }).run(next, createBuiltinRegistry(next.profile))
|
||||
expect(failed.installError?.message).toBe('offline-string')
|
||||
})
|
||||
|
||||
it('reconciles a child option selected in the feature tree', async () => {
|
||||
const project = await committedProject()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
let installs = 0
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[{ value: 'feature:persistence', choices: ['sqlite'] }], true,
|
||||
]), outputBuffer().stream, async () => { installs += 1 })
|
||||
const result = await workflow.run(project, registry)
|
||||
expect(result.commit?.project.cordis.entry('session-persistence')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-session-persistence-sqlite',
|
||||
config: { path: './.sessions/sessions.sqlite' },
|
||||
})
|
||||
expect(installs).toBe(1)
|
||||
})
|
||||
|
||||
it('switches required provider and interface options', async () => {
|
||||
const project = await committedProject()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const workflow = new ConfigWorkflow(new QueuePort([
|
||||
[
|
||||
{ value: 'feature:provider', choices: ['custom'] },
|
||||
{ value: 'feature:app', choices: ['stdio'] },
|
||||
{ value: 'feature:persistence', choices: ['jsonl'] },
|
||||
],
|
||||
'https://provider.example/v1',
|
||||
'custom-key',
|
||||
true,
|
||||
]), outputBuffer().stream, async () => {})
|
||||
const result = await workflow.run(project, registry)
|
||||
const provider = result.commit?.project.cordis.entry('llm-pi-ai')
|
||||
expect(provider?.config?.apiKey).toBeDefined()
|
||||
expect(provider?.config?.baseURL).toBe('https://provider.example/v1')
|
||||
expect(result.commit?.project.cordis.entry('stdio')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-loop')).toBeDefined()
|
||||
expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
13
packages/sdk/scripts/tsconfig.json
Normal file
13
packages/sdk/scripts/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../helper" },
|
||||
{ "path": "../../ui/app-boot" },
|
||||
{ "path": "../../../vendor/cordis" }
|
||||
]
|
||||
}
|
||||
22
packages/sdk/scripts/tsdown.config.ts
Normal file
22
packages/sdk/scripts/tsdown.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Bundle each public or runtime entry and mirror package-owned terminal templates. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024',
|
||||
fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/dev/tsdown-config.js'], outDir: 'lib/dev', format: ['esm'], platform: 'node',
|
||||
target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/local-plugin-loader-hooks.js'], outDir: 'lib', format: ['esm'], platform: 'node',
|
||||
target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false,
|
||||
},
|
||||
])
|
||||
Reference in New Issue
Block a user