refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
74
packages/scaffold/scripts/src/args.ts
Normal file
74
packages/scaffold/scripts/src/args.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Commander adapter for the dsh-sdk subcommand surface.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/args
|
||||
*/
|
||||
|
||||
import { parseArgs as parseNodeArgs } from 'node:util'
|
||||
import { Command } from 'commander'
|
||||
|
||||
/** Commands implemented by the dsh-sdk launcher. */
|
||||
type DshSdkCommand = 'start' | 'dev' | 'build' | 'config' | 'create'
|
||||
|
||||
/** Parsed dsh-sdk invocation. */
|
||||
export interface DshSdkArgs {
|
||||
command?: DshSdkCommand
|
||||
target?: string
|
||||
source?: 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 parseDshSdkArgs(argv: readonly string[]): DshSdkArgs {
|
||||
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: DshSdkArgs | undefined
|
||||
const program = new Command()
|
||||
.name('dsh-sdk')
|
||||
.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.command('create <source>').helpOption(false).action((source: string) => {
|
||||
parsed = { command: 'create', source, 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-sdk command did not resolve')
|
||||
if (parsed.command === 'config' && passthrough.length > 0) {
|
||||
throw new Error('dsh-sdk config does not accept forwarded arguments')
|
||||
}
|
||||
return { ...parsed, forwarded: [...parsed.forwarded, ...passthrough] }
|
||||
}
|
||||
10
packages/scaffold/scripts/src/bin.ts
Normal file
10
packages/scaffold/scripts/src/bin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Self-executing dsh-sdk launcher.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/bin
|
||||
*/
|
||||
|
||||
import { runDshSdkCommand } from './command.ts'
|
||||
|
||||
process.exitCode = await runDshSdkCommand()
|
||||
94
packages/scaffold/scripts/src/build.ts
Normal file
94
packages/scaffold/scripts/src/build.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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 {
|
||||
const hasConfigFile = [
|
||||
'tsdown.config.ts', 'tsdown.config.mts', 'tsdown.config.cts',
|
||||
'tsdown.config.js', 'tsdown.config.mjs', 'tsdown.config.cjs',
|
||||
'tsdown.config.json',
|
||||
]
|
||||
.some(name => existsSync(resolve(root, name)))
|
||||
if (hasConfigFile) return true
|
||||
let manifestText: string
|
||||
try {
|
||||
manifestText = readFileSync(resolve(root, 'package.json'), 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
const manifest: unknown = JSON.parse(manifestText)
|
||||
return manifest !== null && !Array.isArray(manifest) && typeof manifest === 'object'
|
||||
&& Object.hasOwn(manifest, 'tsdown')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-sdk 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)}`)
|
||||
}
|
||||
76
packages/scaffold/scripts/src/command.ts
Normal file
76
packages/scaffold/scripts/src/command.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Internal dsh-sdk command composition used by the package bin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/command
|
||||
*/
|
||||
|
||||
import { parseDshSdkArgs } from './args.ts'
|
||||
import { runProjectBuild } from './build.ts'
|
||||
import { runConfigCommand, type ConfigCommandContext } from './config.ts'
|
||||
import { runCreatePluginCommand } from './create-plugin.ts'
|
||||
import { runSDK } from './runtime.ts'
|
||||
import { reportCommandTelemetry, type CommandTelemetryEvent } from './telemetry.ts'
|
||||
import { DSH_SDK_TEMPLATES } from './templates/dsh-sdk-templates.ts'
|
||||
|
||||
/** Injectable process and command boundaries used by the dsh-sdk bin. */
|
||||
export interface DshSdkCommandContext extends ConfigCommandContext {
|
||||
cwd: string
|
||||
stdin: NodeJS.ReadStream
|
||||
stdout: NodeJS.WriteStream
|
||||
stderr: NodeJS.WriteStream
|
||||
run?: typeof runSDK
|
||||
build?: typeof runProjectBuild
|
||||
config?: typeof runConfigCommand
|
||||
createPlugin?: typeof runCreatePluginCommand
|
||||
telemetry?: (event: CommandTelemetryEvent) => Promise<void>
|
||||
}
|
||||
|
||||
/** Run one parsed dsh-sdk command and return its process exit code. */
|
||||
export async function runDshSdkCommand(
|
||||
argv: readonly string[] = process.argv.slice(2),
|
||||
context: DshSdkCommandContext = {
|
||||
cwd: process.cwd(),
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
},
|
||||
): Promise<number> {
|
||||
const startedAt = Date.now()
|
||||
let command: string | undefined
|
||||
let success = true
|
||||
try {
|
||||
const args = parseDshSdkArgs(argv)
|
||||
if (args.help || !args.command) {
|
||||
context.stdout.write(DSH_SDK_TEMPLATES.usage.render({}))
|
||||
return 0
|
||||
}
|
||||
command = args.command
|
||||
const run = context.run ?? runSDK
|
||||
const build = context.build ?? runProjectBuild
|
||||
const config = context.config ?? runConfigCommand
|
||||
const createPlugin = context.createPlugin ?? runCreatePluginCommand
|
||||
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) { success = false; return 1 }
|
||||
break
|
||||
}
|
||||
/* v8 ignore next -- Commander requires <source>, so create never dispatches without it */
|
||||
case 'create': await createPlugin(args.source ?? '', context); break
|
||||
}
|
||||
return 0
|
||||
} catch (error) {
|
||||
success = false
|
||||
context.stderr.write(`dsh-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
return 1
|
||||
} finally {
|
||||
if (command !== undefined) {
|
||||
/* v8 ignore next -- production telemetry wiring is exercised by the built-bin smoke */
|
||||
const telemetry = context.telemetry ?? reportCommandTelemetry
|
||||
await telemetry({ command, cwd: context.cwd, durationMs: Date.now() - startedAt, success })
|
||||
}
|
||||
}
|
||||
}
|
||||
37
packages/scaffold/scripts/src/config.ts
Normal file
37
packages/scaffold/scripts/src/config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* dsh-sdk 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-sdk 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-sdk 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)
|
||||
}
|
||||
241
packages/scaffold/scripts/src/config/config-workflow.ts
Normal file
241
packages/scaffold/scripts/src/config/config-workflow.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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 RunInterface,
|
||||
type SdkProject,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
import { DSH_SDK_TEMPLATES } from '../templates/dsh-sdk-templates.ts'
|
||||
|
||||
/** Config result, including an install failure that happened after commit. */
|
||||
export interface ConfigWorkflowResult {
|
||||
commit?: ProjectCommitResult<SdkProject>
|
||||
installError?: Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive desired end-state for a config run: the complete set of enabled
|
||||
* features, with options and any secrets/values a newly installed feature needs.
|
||||
* Features not listed are reconciled to disabled, exactly as an interactive tree
|
||||
* selection would be. Custom (non-feature) cordis plugins keep their current state;
|
||||
* toggling them headlessly is not yet supported.
|
||||
*/
|
||||
export interface ConfigPlan {
|
||||
features: readonly FeatureSelection[]
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
function targetRunInterface(
|
||||
current: RunInterface,
|
||||
desired: ReadonlyMap<string, NestedMultiSelectValue<string, string>>,
|
||||
): RunInterface {
|
||||
const selected = desired.get('feature:app')?.choices[0]
|
||||
return selected === 'acp' || selected === 'embed' ? selected : current
|
||||
}
|
||||
|
||||
/** 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, plan?: ConfigPlan): 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 = plan
|
||||
? [
|
||||
...plan.features.map(selection => ({
|
||||
value: featureTarget(registry.get(selection.id)),
|
||||
choices: selection.options,
|
||||
})),
|
||||
...custom
|
||||
.filter(entry => !entry.disabled)
|
||||
.map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })),
|
||||
]
|
||||
: 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]))
|
||||
const targetProfile = {
|
||||
...project.profile,
|
||||
runInterface: targetRunInterface(project.profile.runInterface, desiredByTarget),
|
||||
}
|
||||
for (const feature of features) {
|
||||
/* v8 ignore next -- no current built-in feature is interface-specific */
|
||||
if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature))
|
||||
}
|
||||
|
||||
const plannedById = new Map<FeatureSelection['id'], FeatureSelection>(
|
||||
(plan?.features ?? []).map(selection => [selection.id, selection]),
|
||||
)
|
||||
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, plannedById.get(feature.id))
|
||||
}
|
||||
|
||||
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_SDK_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,
|
||||
planned?: FeatureSelection,
|
||||
): 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, planned?.secrets ?? {}, planned?.values ?? {},
|
||||
)
|
||||
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, planned?.secrets ?? {}, planned?.values ?? {},
|
||||
)
|
||||
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`)
|
||||
}
|
||||
}
|
||||
91
packages/scaffold/scripts/src/create-plugin.ts
Normal file
91
packages/scaffold/scripts/src/create-plugin.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* dsh-sdk create command: add an external Cordis plugin (github or npm) as a
|
||||
* native package-manager dependency and mount it in cordis.yml.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/create-plugin
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
ClackPromptPort,
|
||||
ConfirmQuestion,
|
||||
SdkProject,
|
||||
createBuiltinRegistry,
|
||||
requireAnswer,
|
||||
type PackageManager,
|
||||
type ProjectCommitResult,
|
||||
type PromptPort,
|
||||
} from '@deepseek-ai/dsh-helper'
|
||||
|
||||
/** Process and interaction slice required by dsh-sdk create. */
|
||||
export interface CreatePluginContext {
|
||||
cwd: string
|
||||
stdin: NodeJS.ReadStream
|
||||
stdout: NodeJS.WriteStream
|
||||
port?: PromptPort
|
||||
add?: (manager: PackageManager, spec: string, cwd: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Result of a create run; `undefined` when the confirmation was declined. */
|
||||
export type CreatePluginResult = ProjectCommitResult<SdkProject> | undefined
|
||||
|
||||
/** Derive a stable cordis entry id from a package name's last path segment. */
|
||||
function pluginId(packageName: string): string {
|
||||
const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName
|
||||
const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
/* v8 ignore next -- a valid npm package name always yields a non-empty id */
|
||||
if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`)
|
||||
return id
|
||||
}
|
||||
|
||||
/** Read the direct dependency names declared in a project's package.json. */
|
||||
async function dependencyNames(cwd: string): Promise<Set<string>> {
|
||||
const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, unknown>
|
||||
}
|
||||
/* v8 ignore next -- generated projects always declare a dependencies map */
|
||||
return new Set(Object.keys(manifest.dependencies ?? {}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one external plugin dependency to the current project and mount it.
|
||||
* @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`).
|
||||
* @param context - process, interaction, and dependency-add boundaries.
|
||||
* @returns the commit result, or `undefined` when the confirmation was declined.
|
||||
*/
|
||||
export async function runCreatePluginCommand(
|
||||
source: string,
|
||||
context: CreatePluginContext,
|
||||
): Promise<CreatePluginResult> {
|
||||
const spec = source.trim()
|
||||
if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)')
|
||||
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
|
||||
throw new Error('dsh-sdk create requires an interactive TTY')
|
||||
}
|
||||
const project = await SdkProject.open(context.cwd)
|
||||
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
|
||||
const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout)
|
||||
const confirmed = requireAnswer(await new ConfirmQuestion({
|
||||
id: 'create.confirm',
|
||||
message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`,
|
||||
initialValue: true,
|
||||
}).resolve(port))
|
||||
if (!confirmed) return undefined
|
||||
|
||||
const before = await dependencyNames(context.cwd)
|
||||
/* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */
|
||||
const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd))
|
||||
await add(project.profile.packageManager, spec, context.cwd)
|
||||
const after = await dependencyNames(context.cwd)
|
||||
const added = [...after].filter(name => !before.has(name))
|
||||
if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`)
|
||||
|
||||
const reopened = await SdkProject.open(context.cwd)
|
||||
const registry = createBuiltinRegistry(reopened.profile)
|
||||
const edit = reopened.edit(registry)
|
||||
for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName)
|
||||
const commit = await edit.commit()
|
||||
context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`)
|
||||
return commit
|
||||
}
|
||||
7
packages/scaffold/scripts/src/dev/tsdown-config.ts
Normal file
7
packages/scaffold/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'
|
||||
11
packages/scaffold/scripts/src/index.ts
Normal file
11
packages/scaffold/scripts/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Public DeepSeek Harness SDK runtime entry points.
|
||||
*
|
||||
* FIXME: rename to `@deepseek-ai/dsh-sdk-scripts` before the first tagged release —
|
||||
* the current name is indefensibly generic as a published name
|
||||
* ([regrouping Agent Note](../../../../.agents/notes/proposed/architecture/2026-07-29-package-regrouping.md)).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts
|
||||
*/
|
||||
|
||||
export { runSDK, startSDK, type SdkBootContext } from './runtime.ts'
|
||||
30
packages/scaffold/scripts/src/invariant.ts
Normal file
30
packages/scaffold/scripts/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-scripts`.
|
||||
* @module @deepseek-ai/dsh-scripts/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-scripts'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'scripts-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
|
||||
* generated output and consumer tests cover its contract.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
27
packages/scaffold/scripts/src/local-plugin-loader-hooks.ts
Normal file
27
packages/scaffold/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/scaffold/scripts/src/runtime.ts
Normal file
137
packages/scaffold/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-sdk 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-sdk', dirname(absolute))
|
||||
installFailLoud('dsh-sdk')
|
||||
return boot('dsh-sdk', 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,
|
||||
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-sdk 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-sdk target ${target} must export function main()`)
|
||||
}
|
||||
const argv = [...options.argv ?? []]
|
||||
return module.main({
|
||||
argv,
|
||||
args: parseSdkBootArgs(argv),
|
||||
cwd,
|
||||
mode: options.dev ? 'dev' : 'start',
|
||||
})
|
||||
}
|
||||
63
packages/scaffold/scripts/src/telemetry.ts
Normal file
63
packages/scaffold/scripts/src/telemetry.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Launcher-side telemetry wiring: resolve consent and send one fire-and-forget
|
||||
* event around each dsh-sdk command. Best-effort — never affects the command's
|
||||
* outcome or exit code.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/telemetry
|
||||
*/
|
||||
|
||||
import {
|
||||
ConsentResolver,
|
||||
TelemetryReporter,
|
||||
buildTelemetryPayload,
|
||||
type ConsentDecision,
|
||||
} from '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
/** One command's telemetry lifecycle facts. */
|
||||
export interface CommandTelemetryEvent {
|
||||
/** The dsh-sdk command that ran. */
|
||||
command: string
|
||||
/** Project directory whose consent, `cordis.yml`, and `package.json` are read. */
|
||||
cwd: string
|
||||
/** Wall-clock duration in milliseconds. */
|
||||
durationMs: number
|
||||
/** Whether the command completed without error. */
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/** Injectable consent and delivery seams for tests. */
|
||||
export interface CommandTelemetryDeps {
|
||||
resolve?: (cwd: string) => Promise<ConsentDecision>
|
||||
reporter?: Pick<TelemetryReporter, 'report' | 'flush'>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve consent for the project and, when allowed, assemble and send one
|
||||
* telemetry event, draining in-flight sends before returning. Swallows every
|
||||
* error so telemetry can never change a command's result.
|
||||
* @param event - the command lifecycle facts.
|
||||
* @param deps - consent and delivery seams; defaults hit the real endpoint.
|
||||
*/
|
||||
export async function reportCommandTelemetry(
|
||||
event: CommandTelemetryEvent,
|
||||
deps: CommandTelemetryDeps = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
/* v8 ignore next -- the production ConsentResolver is exercised by the built-bin smoke */
|
||||
const resolve = deps.resolve ?? (cwd => new ConsentResolver().resolve(cwd))
|
||||
const consent = await resolve(event.cwd)
|
||||
if (!consent.allowed) return
|
||||
const payload = await buildTelemetryPayload({
|
||||
command: event.command,
|
||||
durationMs: event.durationMs,
|
||||
success: event.success,
|
||||
projectDir: event.cwd,
|
||||
})
|
||||
/* v8 ignore next -- the production TelemetryReporter is exercised by the built-bin smoke */
|
||||
const reporter = deps.reporter ?? new TelemetryReporter()
|
||||
reporter.report(payload, consent)
|
||||
await reporter.flush()
|
||||
} catch {
|
||||
// Telemetry is best-effort; a consent, payload, or delivery fault never reaches the command.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Changes were committed, but install failed: {{error}}
|
||||
Retry: {{packageManager}} {{installArgs}}
|
||||
@@ -0,0 +1,8 @@
|
||||
Usage: dsh-sdk <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
|
||||
create <source> Add an external plugin dependency (pkg@version or github:owner/repo#ref) and mount it in cordis.yml
|
||||
21
packages/scaffold/scripts/src/templates/dsh-sdk-templates.ts
Normal file
21
packages/scaffold/scripts/src/templates/dsh-sdk-templates.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Package-owned terminal templates for the dsh-sdk launcher.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scripts/templates/dsh-sdk-templates
|
||||
*/
|
||||
|
||||
import { TextTemplate, type PackageManagerName } from '@deepseek-ai/dsh-helper'
|
||||
|
||||
interface ConfigInstallFailureTemplateModel {
|
||||
error: string
|
||||
packageManager: PackageManagerName
|
||||
installArgs: string
|
||||
}
|
||||
|
||||
/** Compiled dsh-sdk terminal templates. */
|
||||
export const DSH_SDK_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
|
||||
Reference in New Issue
Block a user