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:
imccyu
2026-07-15 18:17:38 +08:00
parent 5a8466b774
commit 42b07a7022
115 changed files with 11315 additions and 27 deletions
+70
View 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
View 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
View 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
View 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
View 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)
}
@@ -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`)
}
}
@@ -0,0 +1,7 @@
/**
* Generated-project tsdown config wrappers.
*
* @module @deepseek-ai/dsh-scripts/dev/tsdown-config
*/
export { PluginBuild, ProjectBuild } from '../build.ts'
+7
View 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'
@@ -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
View 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}}
@@ -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
@@ -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