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:
Tianyi Cui
2026-07-30 03:13:49 +08:00
parent 7e445c3a67
commit 3fc35c91ff
351 changed files with 368 additions and 311 deletions

View File

@@ -0,0 +1,304 @@
/**
* Tree-shaped Clack picker for root checkboxes with finite child options.
*
* @module @deepseek-ai/dsh-helper/questions/clack-nested-multiselect
*/
import { styleText } from 'node:util'
import type { Readable, Writable } from 'node:stream'
import { Prompt, isCancel } from '@clack/core'
import {
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
symbolBar,
} from '@clack/prompts'
import type {
NestedMultiSelectOption,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
} from './prompt-port.ts'
interface NestedPromptOptions<TValue, TChoice> extends NestedMultiSelectRequest<TValue, TChoice> {
input: Readable
output: Writable
}
class NestedPrompt<TValue, TChoice> extends Prompt<readonly NestedMultiSelectValue<TValue, TChoice>[]> {
readonly options: readonly NestedMultiSelectOption<TValue, TChoice>[]
private readonly selected = new Set<TValue>()
private readonly selectedChoices = new Map<TValue, Set<TChoice>>()
private readonly initialSelected: Set<TValue>
private readonly initialChoices: Map<TValue, Set<TChoice>>
private readonly showChanges: boolean
private layer: 'root' | 'choices' = 'root'
private rootCursor = 0
private choiceCursor = 0
constructor(options: NestedPromptOptions<TValue, TChoice>) {
super({
input: options.input,
output: options.output,
validate: value => NestedPrompt.validate(options.options, value),
render(this: Prompt<readonly NestedMultiSelectValue<TValue, TChoice>[]>) {
return (this as NestedPrompt<TValue, TChoice>).renderFrame(options.message)
},
}, false)
this.options = options.options
this.showChanges = options.showChanges ?? false
for (const option of options.options) {
if (option.required || option.default) this.selected.add(option.value)
this.selectedChoices.set(option.value, new Set(
option.choices?.filter(choice => choice.default).map(choice => choice.value) ?? [],
))
}
this.initialSelected = new Set(this.selected)
this.initialChoices = new Map([...this.selectedChoices].map(([value, choices]) => [
value, new Set(choices),
]))
this.updateValue()
this.on('cursor', (action) => { this.handleAction(action) })
}
private static validate<TValue, TChoice>(
options: readonly NestedMultiSelectOption<TValue, TChoice>[],
value: readonly NestedMultiSelectValue<TValue, TChoice>[] | undefined,
): string | undefined {
/* v8 ignore next -- NestedPrompt initializes its value before submission validation */
const selected = new Map(value?.map(item => [item.value, item.choices]) ?? [])
for (const option of options) {
if (option.disabled) continue
/* v8 ignore next -- required options initialize selected and cannot be toggled off */
if (option.required && !selected.has(option.value)) return `${option.label} is required`
if (!selected.has(option.value) || !option.choiceMode) continue
const choices = selected.get(option.value)
/* v8 ignore next -- selected.has above guarantees the map value exists */
if (!choices) continue
const count = choices.length
if (option.choiceMode === 'exclusive' && count !== 1) return `Choose one ${option.label} option`
if (option.choiceMode === 'multiple' && count === 0) return `Choose at least one ${option.label} option`
}
return undefined
}
protected override _shouldSubmit(): boolean {
if (this.layer === 'choices') {
this.leaveChoices()
return false
}
return true
}
private handleAction(action: string | undefined): void {
if (this.layer === 'root') this.handleRootAction(action)
else this.handleChoiceAction(action)
this.updateValue()
}
private handleRootAction(action: string | undefined): void {
if (action === 'up') this.rootCursor = this.move(this.rootCursor, -1, this.options.length)
if (action === 'down') this.rootCursor = this.move(this.rootCursor, 1, this.options.length)
const option = this.options[this.rootCursor]
/* v8 ignore next -- Clack cannot emit a cursor action when the option list is empty */
if (!option) return
if (action === 'space' && !option.required && !option.disabled) {
if (this.selected.has(option.value)) this.selected.delete(option.value)
else this.selected.add(option.value)
}
if (action === 'right' && !option.disabled && option.choices && option.choices.length > 0) {
this.selected.add(option.value)
this.layer = 'choices'
const selected = this.selectedChoices.get(option.value)
const selectedIndex = option.choices.findIndex(choice => selected?.has(choice.value))
this.choiceCursor = Math.max(selectedIndex, 0)
}
}
private handleChoiceAction(action: string | undefined): void {
const rootOption = this.options[this.rootCursor]
/* v8 ignore next -- the choices layer is entered only from a concrete root option */
if (!rootOption) return
/* v8 ignore next -- the choices layer is entered only for a non-empty choices array */
const choices = rootOption.choices ?? []
if (action === 'left') {
this.leaveChoices()
return
}
if (action === 'up') this.choiceCursor = this.move(this.choiceCursor, -1, choices.length)
if (action === 'down') this.choiceCursor = this.move(this.choiceCursor, 1, choices.length)
if ((action === 'up' || action === 'down') && rootOption.choiceMode === 'exclusive') {
const choice = choices[this.choiceCursor]
/* v8 ignore else -- a cursor in the non-empty choices layer always addresses a choice */
if (choice) this.selectedChoices.set(rootOption.value, new Set([choice.value]))
}
if (action !== 'space' && action !== 'right') return
const choice = choices[this.choiceCursor]
/* v8 ignore next -- the choices layer requires a non-empty choice list */
if (!choice) return
/* v8 ignore next -- every root option initializes its choice set in the constructor */
const selected = this.selectedChoices.get(rootOption.value) ?? new Set<TChoice>()
if (rootOption.choiceMode === 'exclusive') {
selected.clear()
selected.add(choice.value)
} else if (selected.has(choice.value)) selected.delete(choice.value)
else selected.add(choice.value)
this.selectedChoices.set(rootOption.value, selected)
}
private move(cursor: number, offset: number, length: number): number {
/* v8 ignore next -- cursor movement is emitted only for a non-empty displayed list */
if (length === 0) return 0
return (cursor + offset + length) % length
}
private updateValue(): void {
this._setValue(this.options.filter(option => this.selected.has(option.value)).map(option => ({
value: option.value,
/* v8 ignore next -- every root option initializes its choice set in the constructor */
choices: [...this.selectedChoices.get(option.value) ?? []],
})))
}
private renderFrame(message: string): string {
const header = `${symbolBar(this.state)} ${message}`
if (this.state === 'submit') {
/* v8 ignore next -- NestedPrompt initializes its value before it can submit */
const summary = (this.value ?? []).map(item => this.options.find(option => option.value === item.value)?.label)
.filter(Boolean).join(', ') || 'none'
return `${symbol(this.state)} ${message}\n${styleText('gray', S_BAR)} ${styleText('dim', summary)}`
}
if (this.state === 'cancel') return `${symbol(this.state)} ${message}`
const body = this.layer === 'root' ? this.renderRoot() : this.renderChoices()
const instructions = this.layer === 'root'
? `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space')} select ${styleText('dim', '→')} configure ${styleText('dim', 'Enter')} confirm`
: `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space/→')} select ${styleText('dim', '←/Enter')} back`
const error = this.state === 'error' ? `\n${styleText('yellow', `${S_BAR_END} ${this.error}`)}` : ''
return `${header}\n${styleText('cyan', S_BAR)} ${body.join(`\n${styleText('cyan', S_BAR)} `)}\n${styleText('cyan', S_BAR_END)} ${instructions}${error}`
}
private renderRoot(): string[] {
return this.options.map((option, index) => {
const active = index === this.rootCursor
const selected = this.selected.has(option.value)
const focus = active ? styleText('cyan', '›') : ' '
const checkbox = selected
? styleText('green', S_CHECKBOX_SELECTED)
: styleText('dim', active ? S_CHECKBOX_ACTIVE : S_CHECKBOX_INACTIVE)
const choices = option.choices?.filter(choice => this.selectedChoices.get(option.value)?.has(choice.value))
.map(choice => choice.label).join(', ')
const suffix = option.choices?.length
? ` ${styleText('dim', `* →${choices ? ` ${choices}` : ''}`)}`
: ''
const required = option.required ? ` ${styleText('yellow', '(required)')}` : ''
const issue = this.choiceIssue(option)
const warningText = option.warning ?? issue
const warning = warningText ? ` ${styleText('yellow', `▲ ${warningText}`)}` : ''
const changed = this.optionChanged(option)
const change = changed ? ` ${styleText('yellow', '● changed')}` : ''
const label = active
? styleText('cyan', option.label)
: changed
? styleText('yellow', option.label)
: selected ? styleText('green', option.label) : styleText('dim', option.label)
const line = `${focus} ${checkbox} ${label}${required}${suffix}${warning}${change}`
return option.disabled ? styleText('gray', line) : line
})
}
private renderChoices(): string[] {
const rootOption = this.options[this.rootCursor]
/* v8 ignore next -- renderChoices runs only after entering from a concrete root option */
if (!rootOption) return []
/* v8 ignore next -- every root option initializes its choice set in the constructor */
const selected = this.selectedChoices.get(rootOption.value) ?? new Set<TChoice>()
const issue = this.choiceIssue(rootOption)
const changed = this.optionChanged(rootOption)
const header = styleText('dim', `${rootOption.label} options`)
+ (issue ? ` ${styleText('yellow', `▲ ${issue}`)}` : '')
+ (changed ? ` ${styleText('yellow', '● changed')}` : '')
const choices = rootOption.choices
/* v8 ignore next -- the choices layer is entered only for a non-empty choices array */
if (!choices) return [header]
return [
header,
...choices.map((choice, index) => {
const active = index === this.choiceCursor
const checked = selected.has(choice.value)
const choiceChanged = this.choiceChanged(rootOption.value, choice.value)
const focus = active ? styleText('cyan', '›') : ' '
const marker = rootOption.choiceMode === 'exclusive'
? checked ? styleText('green', S_RADIO_ACTIVE) : styleText('dim', S_RADIO_INACTIVE)
: checked ? styleText('green', S_CHECKBOX_SELECTED) : styleText('dim', S_CHECKBOX_INACTIVE)
const label = active
? styleText('cyan', choice.label)
: choiceChanged
? styleText('yellow', choice.label)
: checked ? styleText('green', choice.label) : styleText('dim', choice.label)
const change = choiceChanged ? ` ${styleText('yellow', '●')}` : ''
return `${focus} ${marker} ${label}${change}`
}),
]
}
private optionChanged(option: NestedMultiSelectOption<TValue, TChoice>): boolean {
if (!this.showChanges) return false
const selected = this.selected.has(option.value)
const initiallySelected = this.initialSelected.has(option.value)
if (selected !== initiallySelected) return true
if (!selected) return false
/* v8 ignore next -- every root option initializes both current and baseline option sets */
const current = this.selectedChoices.get(option.value) ?? new Set<TChoice>()
/* v8 ignore next -- every root option initializes both current and baseline option sets */
const initial = this.initialChoices.get(option.value) ?? new Set<TChoice>()
return current.size !== initial.size || [...current].some(value => !initial.has(value))
}
private choiceChanged(value: TValue, choice: TChoice): boolean {
if (!this.showChanges) return false
return this.selectedChoices.get(value)?.has(choice) !== this.initialChoices.get(value)?.has(choice)
}
private choiceIssue(option: NestedMultiSelectOption<TValue, TChoice>): string | undefined {
if (option.disabled || !this.selected.has(option.value) || !option.choiceMode) return undefined
/* v8 ignore next -- every root option initializes its choice set in the constructor */
const count = this.selectedChoices.get(option.value)?.size ?? 0
if (option.choiceMode === 'exclusive' && count !== 1) return 'choose one'
if (option.choiceMode === 'multiple' && count === 0) return 'choose at least one'
return undefined
}
private leaveChoices(): boolean {
const option = this.options[this.rootCursor]
/* v8 ignore next -- leaveChoices runs only after entering from a concrete root option */
if (!option) return false
const issue = this.choiceIssue(option)
if (issue) {
this.error = `${option.label}: ${issue}`
this.state = 'error'
return false
}
this.error = ''
this.layer = 'root'
return true
}
}
/** Run the nested picker with Clack's standard cancellation symbol. */
export async function clackNestedMultiselect<TValue, TChoice>(
request: NestedPromptOptions<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
const value = await new NestedPrompt(request).prompt()
return isCancel(value)
? { status: 'cancelled' }
: {
status: 'answered',
/* v8 ignore next -- NestedPrompt initializes its value before it can submit */
value: value ?? [],
}
}

View File

@@ -0,0 +1,127 @@
/**
* Thin @clack/prompts adapter for the shared prompt port.
*
* @module @deepseek-ai/dsh-helper/questions/clack-prompt-port
*/
import type { Readable, Writable } from 'node:stream'
import { styleText } from 'node:util'
import {
confirm,
isCancel,
multiselect,
password,
select,
text,
S_WARN,
} from '@clack/prompts'
import type { Option } from '@clack/prompts'
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
PromptPort,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from './prompt-port.ts'
import { clackNestedMultiselect } from './clack-nested-multiselect.ts'
function outcome<T>(value: T | symbol): PromptOutcome<T> {
return isCancel(value) ? { status: 'cancelled' } : { status: 'answered', value }
}
function clackOptions<T>(values: readonly import('./prompt-port.ts').PromptOption<T>[]): Option<T>[] {
return values.map(value => ({
value: value.value,
label: value.label,
...value.hint === undefined ? {} : { hint: value.hint },
...value.disabled === undefined ? {} : { disabled: value.disabled },
})) as Option<T>[]
}
/** Clack-backed prompt adapter with injectable streams for snapshots and tests. */
export class ClackPromptPort implements PromptPort {
private readonly input: Readable
private readonly output: Writable
/** Bind all prompts to one input/output pair. */
constructor(input: Readable = process.stdin, output: Writable = process.stdout) {
this.input = input
this.output = output
}
/** Ask for visible text through clack. */
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
return outcome(await text({
message: request.message,
...request.placeholder === undefined ? {} : { placeholder: request.placeholder },
...request.initialValue === undefined ? {} : { initialValue: request.initialValue },
...request.defaultValue === undefined ? {} : { defaultValue: request.defaultValue },
...request.validate === undefined
? {}
: {
/* v8 ignore next -- value/default precedence is exercised through the adapter contract tests */
validate: value => request.validate?.(value || request.defaultValue || ''),
},
input: this.input,
output: this.output,
}))
}
/** Ask for a masked secret through clack. */
async secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return outcome(await password({
message: request.message,
...request.validate === undefined ? {} : {
/* v8 ignore next -- @clack/password always calls validation with a string; fallback is defensive */
validate: value => request.validate?.(value ?? ''),
},
input: this.input,
output: this.output,
}))
}
/** Ask for one option through clack. */
async select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
return outcome(await select({
...request,
options: clackOptions(request.options),
input: this.input,
output: this.output,
}))
}
/** Ask for multiple options through clack. */
async multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return outcome(await multiselect({
message: request.message,
options: clackOptions(request.options),
...request.initialValues === undefined ? {} : { initialValues: [...request.initialValues] },
...request.required === undefined ? {} : { required: request.required },
input: this.input,
output: this.output,
}))
}
/** Ask for confirmation through clack. */
async confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
return outcome(await confirm({
message: request.tone === 'warning'
? styleText('yellow', `${S_WARN} ${request.message}`)
: request.message,
...request.initialValue === undefined ? {} : { initialValue: request.initialValue },
input: this.input,
output: this.output,
}))
}
/** Select root values and finite child options in one tree prompt. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return clackNestedMultiselect({ ...request, input: this.input, output: this.output })
}
}

View File

@@ -0,0 +1,97 @@
/**
* Non-interactive prompt port for headless create/config and skill-driven runs.
*
* @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
*/
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
PromptPort,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from './prompt-port.ts'
/**
* Raised when a headless run reaches a decision that was neither prefilled nor
* carries a usable default. The message names the unanswered prompt so an agent
* or CI caller can see exactly which input the spec must supply.
*/
export class HeadlessPromptError extends Error {
/** The unanswered prompt's user-facing message. */
readonly prompt: string
/** Build an error naming the unanswered prompt. */
constructor(prompt: string) {
super(`headless run needs an answer for: ${prompt}`)
this.name = 'HeadlessPromptError'
this.prompt = prompt
}
}
/** Resolve an answered outcome. */
function answered<T>(value: T): Promise<PromptOutcome<T>> {
return Promise.resolve({ status: 'answered', value })
}
/** Reject with a named unanswered-prompt error. */
function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
return Promise.reject(new HeadlessPromptError(message))
}
/**
* A {@link PromptPort} that never blocks on a terminal.
*
* Answers are expected to arrive as prefilled values through the `Question` /
* `FeatureConfigurator` layers, so in a fully specified run this port is never
* reached. When it *is* reached, it takes the prompt's own declared default
* (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
* {@link HeadlessPromptError}. Nested feature selection has no scalar default,
* so it always fails loud — headless callers must supply the feature set through
* the spec rather than the tree picker.
*/
export class HeadlessPromptPort implements PromptPort {
/** Answer visible text from its default, or fail loud. */
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
const fallback = request.initialValue ?? request.defaultValue
if (fallback === undefined) return unanswered(request.message)
const diagnostic = request.validate?.(fallback)
if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
return answered(fallback)
}
/** A secret has no safe default: always fail loud. */
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return unanswered(request.message)
}
/** Answer a single choice from its initial value, or fail loud. */
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Answer a multi-choice from its initial values, or fail loud when required. */
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
const initial = request.initialValues ?? []
if (request.required && initial.length === 0) return unanswered(request.message)
return answered(initial)
}
/** Answer a confirmation from its initial value, or fail loud. */
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Nested feature selection has no scalar default: always fail loud. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return unanswered(request.message)
}
}

View File

@@ -0,0 +1,124 @@
/**
* Terminal-prompt port shared by create and config workflows.
*
* @module @deepseek-ai/dsh-helper/questions/prompt-port
*/
/** One selectable prompt option. */
export interface PromptOption<T> {
value: T
label: string
hint?: string
disabled?: boolean
}
/** Answer or explicit cancellation returned by every prompt. */
export type PromptOutcome<T> =
| { status: 'answered'; value: T }
| { status: 'cancelled' }
/** Input for one text prompt. */
export interface TextPromptRequest {
message: string
placeholder?: string
initialValue?: string
defaultValue?: string
validate?: (value: string) => string | undefined
}
/** Input for one masked secret prompt. */
export interface SecretPromptRequest {
message: string
validate?: (value: string) => string | undefined
}
/** Input for one single-choice prompt. */
export interface SelectPromptRequest<T> {
message: string
options: readonly PromptOption<T>[]
initialValue?: T
}
/** Input for one additive multi-choice prompt. */
export interface MultiSelectPromptRequest<T> {
message: string
options: readonly PromptOption<T>[]
initialValues?: readonly T[]
required?: boolean
}
/** Input for one yes/no prompt. */
export interface ConfirmPromptRequest {
message: string
initialValue?: boolean
tone?: 'default' | 'warning'
}
/** One nested choice under a multi-select option. */
interface NestedSelectChoice<T> {
value: T
label: string
default?: boolean
}
/** One root option with optional child option configuration. */
export interface NestedMultiSelectOption<TValue, TChoice> {
value: TValue
label: string
required?: boolean
default?: boolean
disabled?: boolean
warning?: string
choiceMode?: 'exclusive' | 'multiple'
choices?: readonly NestedSelectChoice<TChoice>[]
}
/** Input for a tree-shaped feature-style picker. */
export interface NestedMultiSelectRequest<TValue, TChoice> {
message: string
options: readonly NestedMultiSelectOption<TValue, TChoice>[]
showChanges?: boolean
}
/** One selected root option and its child options. */
export interface NestedMultiSelectValue<TValue, TChoice> {
value: TValue
choices: readonly TChoice[]
}
/** Interaction boundary consumed by typed question objects. */
export interface PromptPort {
/** Ask for one line of visible text. */
text(request: TextPromptRequest): Promise<PromptOutcome<string>>
/** Ask for one masked value. */
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>>
/** Ask for exactly one option. */
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>>
/** Ask for zero or more options. */
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>>
/** Ask for a boolean confirmation. */
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>>
/** Select root options and configure finite child options in one tree prompt. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>>
}
/** Error used when a workflow chooses to turn prompt cancellation into command cancellation. */
export class PromptCancelledError extends Error {
/** Create a stable cancellation error. */
constructor(message = 'operation cancelled') {
super(message)
this.name = 'PromptCancelledError'
}
}
/**
* Return an answered value or throw the shared cancellation error.
* @param outcome - prompt result to unwrap.
* @returns answered value.
*/
export function requireAnswer<T>(outcome: PromptOutcome<T>): T {
if (outcome.status === 'cancelled') throw new PromptCancelledError()
return outcome.value
}

View File

@@ -0,0 +1,206 @@
/**
* Typed question objects with prefill, validation, and prompt behavior together.
*
* @module @deepseek-ai/dsh-helper/questions/question
*/
import type { PromptOption, PromptOutcome, PromptPort } from './prompt-port.ts'
function resolvePrefilled(
id: string,
value: string | undefined,
validate: ((value: string) => string | undefined) | undefined,
): PromptOutcome<string> | undefined {
if (value === undefined) return undefined
const diagnostic = validate?.(value)
if (diagnostic) throw new Error(`${id}: ${diagnostic}`)
return { status: 'answered', value }
}
/** A typed business question resolved from prefilled input or one prompt call. */
export abstract class Question<T> {
/** Stable question identity used in diagnostics. */
readonly id: string
/** User-facing prompt text. */
readonly message: string
protected constructor(id: string, message: string) {
this.id = id
this.message = message
}
/**
* Resolve a prefilled answer without prompting, or ask through the port.
* @param port - prompt interaction boundary.
* @param prefilled - optional value supplied by CLI or current project state.
* @returns answered or cancelled prompt outcome.
*/
abstract resolve(port: PromptPort, prefilled?: T): Promise<PromptOutcome<T>>
}
/** Visible single-line text question. */
export class TextQuestion extends Question<string> {
/** Light hint displayed when no text has been entered. */
readonly placeholder: string | undefined
/** Editable value displayed in the input. */
readonly initialValue: string | undefined
/** Value accepted when the user submits an empty input. */
readonly defaultValue: string | undefined
private readonly validate: ((value: string) => string | undefined) | undefined
/** Configure one text question. */
constructor(options: {
id: string
message: string
placeholder?: string
initialValue?: string
defaultValue?: string
validate?: (value: string) => string | undefined
}) {
super(options.id, options.message)
this.placeholder = options.placeholder
this.initialValue = options.initialValue
this.defaultValue = options.defaultValue
this.validate = options.validate
}
/** Validate prefilled text or ask for it. */
override async resolve(port: PromptPort, prefilled?: string): Promise<PromptOutcome<string>> {
const resolved = resolvePrefilled(this.id, prefilled, this.validate)
if (resolved) return resolved
return port.text({
message: this.message,
...this.placeholder === undefined ? {} : { placeholder: this.placeholder },
...this.initialValue === undefined ? {} : { initialValue: this.initialValue },
...this.defaultValue === undefined ? {} : { defaultValue: this.defaultValue },
...this.validate === undefined ? {} : { validate: this.validate },
})
}
}
/** Masked secret question whose empty-input semantics are set by its caller. */
export class SecretQuestion extends Question<string> {
private readonly validate: ((value: string) => string | undefined) | undefined
/** Configure one secret question. */
constructor(options: {
id: string
message: string
validate?: (value: string) => string | undefined
}) {
super(options.id, options.message)
this.validate = options.validate
}
/** Validate a prefilled secret or ask for a masked value. */
override async resolve(port: PromptPort, prefilled?: string): Promise<PromptOutcome<string>> {
const resolved = resolvePrefilled(this.id, prefilled, this.validate)
if (resolved) return resolved
return port.secret({
message: this.message,
...this.validate === undefined ? {} : { validate: this.validate },
})
}
}
/** Single-choice question. */
export class SelectQuestion<T> extends Question<T> {
/** Available choices in display order. */
readonly options: readonly PromptOption<T>[]
/** Initially focused choice. */
readonly initialValue: T | undefined
/** Configure one single-choice question. */
constructor(options: {
id: string
message: string
options: readonly PromptOption<T>[]
initialValue?: T
}) {
super(options.id, options.message)
this.options = options.options
this.initialValue = options.initialValue
}
/** Validate a prefilled option or ask for one choice. */
override async resolve(port: PromptPort, prefilled?: T): Promise<PromptOutcome<T>> {
if (prefilled !== undefined) {
if (!this.options.some(option => Object.is(option.value, prefilled) && !option.disabled)) {
throw new Error(`${this.id}: unknown or disabled option ${String(prefilled)}`)
}
return { status: 'answered', value: prefilled }
}
return port.select({
message: this.message,
options: this.options,
...this.initialValue === undefined ? {} : { initialValue: this.initialValue },
})
}
}
/** Additive multi-choice question. */
export class MultiSelectQuestion<T> extends Question<readonly T[]> {
readonly options: readonly PromptOption<T>[]
readonly initialValues: readonly T[]
readonly required: boolean
/** Configure one multi-choice question. */
constructor(options: {
id: string
message: string
options: readonly PromptOption<T>[]
initialValues?: readonly T[]
required?: boolean
}) {
super(options.id, options.message)
this.options = options.options
this.initialValues = options.initialValues ?? []
this.required = options.required ?? false
}
/** Validate prefilled values or ask for an additive selection. */
override async resolve(port: PromptPort, prefilled?: readonly T[]): Promise<PromptOutcome<readonly T[]>> {
if (prefilled !== undefined) {
for (const value of prefilled) {
/* v8 ignore next -- unknown, disabled, and accepted values are each pinned by the question tests */
if (!this.options.some(option => Object.is(option.value, value) && option.disabled !== true)) {
throw new Error(`${this.id}: unknown or disabled option ${String(value)}`)
}
}
if (this.required && prefilled.length === 0) throw new Error(`${this.id}: choose at least one option`)
return { status: 'answered', value: prefilled }
}
return port.multiselect({
message: this.message,
options: this.options,
initialValues: this.initialValues,
required: this.required,
})
}
}
/** Boolean confirmation question. */
export class ConfirmQuestion extends Question<boolean> {
/** Answer selected by pressing Enter. */
readonly initialValue: boolean
/** Visual severity used by the prompt adapter. */
readonly tone: 'default' | 'warning'
/** Configure one confirmation question. */
constructor(options: {
id: string
message: string
initialValue?: boolean
tone?: 'default' | 'warning'
}) {
super(options.id, options.message)
this.initialValue = options.initialValue ?? true
this.tone = options.tone ?? 'default'
}
/** Return a prefilled boolean or ask for confirmation. */
override async resolve(port: PromptPort, prefilled?: boolean): Promise<PromptOutcome<boolean>> {
if (prefilled !== undefined) return { status: 'answered', value: prefilled }
return port.confirm({ message: this.message, initialValue: this.initialValue, tone: this.tone })
}
}