Merge remote-tracking branch 'origin/codex/goal-session' into codex/commands
This commit is contained in:
@@ -26,6 +26,7 @@ export class FeatureConfigurator {
|
||||
* @param current - currently installed selection, when configuring.
|
||||
* @param prefilledOptions - options already chosen by a tree picker.
|
||||
* @param prefilledSecrets - non-interactive secret values supplied by creation.
|
||||
* @param prefilledValues - non-interactive value inputs supplied by a headless spec.
|
||||
* @returns normalized selection with captured values and secrets.
|
||||
*/
|
||||
async configure(
|
||||
@@ -34,6 +35,7 @@ export class FeatureConfigurator {
|
||||
current?: FeatureSelection,
|
||||
prefilledOptions?: readonly string[],
|
||||
prefilledSecrets: Readonly<Record<string, string>> = {},
|
||||
prefilledValues: Readonly<Record<string, unknown>> = {},
|
||||
): Promise<FeatureSelection> {
|
||||
let options: readonly string[]
|
||||
switch (feature.mode) {
|
||||
@@ -69,6 +71,11 @@ export class FeatureConfigurator {
|
||||
id: feature.id,
|
||||
options,
|
||||
}
|
||||
const coercedPrefilled: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(prefilledValues)) {
|
||||
if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
|
||||
coercedPrefilled[key] = value
|
||||
}
|
||||
const values: Record<string, string> = {}
|
||||
for (const input of feature.valueInputs(selected, profile)) {
|
||||
const existing = current?.values?.[input.id]
|
||||
@@ -81,7 +88,7 @@ export class FeatureConfigurator {
|
||||
...existing === undefined ? {} : { initialValue: existing },
|
||||
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
|
||||
})
|
||||
values[input.id] = requireAnswer(await question.resolve(this.port))
|
||||
values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
|
||||
}
|
||||
const base: FeatureSelection = Object.keys(values).length === 0
|
||||
? selected
|
||||
|
||||
@@ -43,3 +43,4 @@ export {
|
||||
} from './questions/question.ts'
|
||||
export type { Question } from './questions/question.ts'
|
||||
export { ClackPromptPort } from './questions/clack-prompt-port.ts'
|
||||
export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'
|
||||
|
||||
@@ -58,17 +58,38 @@ export function scrubEnvironment(environment: NodeJS.ProcessEnv = process.env):
|
||||
|
||||
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
||||
export class NodeCommandRunner implements CommandRunner {
|
||||
/** Spawn one child and settle only after its exit. */
|
||||
private readonly output: NodeJS.WritableStream | undefined
|
||||
|
||||
/**
|
||||
* @param output - redirect target for child stdout+stderr; the child inherits
|
||||
* this process's stdio when absent. Callers whose own stdout carries a machine
|
||||
* protocol (create-sdk --json NDJSON) redirect child output to keep the
|
||||
* protocol stream pure.
|
||||
*/
|
||||
constructor(output?: NodeJS.WritableStream) {
|
||||
this.output = output
|
||||
}
|
||||
|
||||
/** Spawn one child and settle only after exit, with redirected stdio drained. */
|
||||
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = this.output
|
||||
if (output === undefined) {
|
||||
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
return
|
||||
}
|
||||
const child = spawn(command, [...args], {
|
||||
cwd,
|
||||
env: scrubEnvironment(),
|
||||
stdio: 'inherit',
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
shell: false,
|
||||
})
|
||||
child.stdout.pipe(output, { end: false })
|
||||
child.stderr.pipe(output, { end: false })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -148,6 +169,25 @@ export abstract class PackageManager {
|
||||
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build add-dependency command arguments for one already-normalized source spec.
|
||||
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
|
||||
* @returns arguments following the manager executable.
|
||||
*/
|
||||
addCommand(spec: string): readonly string[] {
|
||||
return ['add', spec]
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
|
||||
* @param spec - a package-manager-native dependency source.
|
||||
* @param cwd - project directory.
|
||||
* @param runner - optional subprocess boundary.
|
||||
*/
|
||||
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
||||
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
|
||||
}
|
||||
|
||||
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
|
||||
const result = await runner.run(this.name, args, cwd)
|
||||
if (result.signal !== null) {
|
||||
@@ -184,6 +224,11 @@ export class NpmPackageManager extends PackageManager {
|
||||
override linkSpec(relativePath: string): string {
|
||||
return `file:${relativePath}`
|
||||
}
|
||||
|
||||
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
|
||||
override addCommand(spec: string): readonly string[] {
|
||||
return ['install', spec]
|
||||
}
|
||||
}
|
||||
|
||||
/** pnpm workspace behavior. */
|
||||
|
||||
@@ -220,6 +220,23 @@ export class ProjectEditSession implements FeatureProjectView {
|
||||
this.addedPlugins.add(entry.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a Cordis entry for an external dependency the package manager has already
|
||||
* added (github or npm), without generating files or re-adding the dependency.
|
||||
* @param id - stable Cordis config entry id.
|
||||
* @param packageName - the installed dependency's package name.
|
||||
*/
|
||||
addExternalPlugin(id: string, packageName: string): void {
|
||||
this.assertOpen()
|
||||
if (!this.manifest().npmDependency(packageName)) {
|
||||
throw new Error(`external plugin dependency is not installed: ${packageName}`)
|
||||
}
|
||||
const cordis = this.cordis()
|
||||
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
|
||||
cordis.addEntry({ id, name: packageName })
|
||||
this.addedPlugins.add(id)
|
||||
}
|
||||
|
||||
/** Enable or disable one custom/manual Cordis config entry by stable id. */
|
||||
setCustomPluginDisabled(id: string, disabled: boolean): void {
|
||||
this.assertOpen()
|
||||
|
||||
97
packages/sdk/helper/src/questions/headless-prompt-port.ts
Normal file
97
packages/sdk/helper/src/questions/headless-prompt-port.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user