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:
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
127
packages/scaffold/helper/src/questions/clack-prompt-port.ts
Normal file
127
packages/scaffold/helper/src/questions/clack-prompt-port.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
124
packages/scaffold/helper/src/questions/prompt-port.ts
Normal file
124
packages/scaffold/helper/src/questions/prompt-port.ts
Normal 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
|
||||
}
|
||||
206
packages/scaffold/helper/src/questions/question.ts
Normal file
206
packages/scaffold/helper/src/questions/question.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user