278 lines
6.7 KiB
TypeScript
278 lines
6.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import {
|
|
WizardCancelledError,
|
|
type WizardProgress,
|
|
type WizardPrompter,
|
|
} from "./prompts.js";
|
|
|
|
export type WizardStepOption = {
|
|
value: unknown;
|
|
label: string;
|
|
hint?: string;
|
|
};
|
|
|
|
export type WizardStep = {
|
|
id: string;
|
|
type:
|
|
| "note"
|
|
| "select"
|
|
| "text"
|
|
| "confirm"
|
|
| "multiselect"
|
|
| "progress"
|
|
| "action";
|
|
title?: string;
|
|
message?: string;
|
|
options?: WizardStepOption[];
|
|
initialValue?: unknown;
|
|
placeholder?: string;
|
|
sensitive?: boolean;
|
|
executor?: "gateway" | "client";
|
|
};
|
|
|
|
export type WizardSessionStatus = "running" | "done" | "cancelled" | "error";
|
|
|
|
export type WizardNextResult = {
|
|
done: boolean;
|
|
step?: WizardStep;
|
|
status: WizardSessionStatus;
|
|
error?: string;
|
|
};
|
|
|
|
type Deferred<T> = {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (err: unknown) => void;
|
|
};
|
|
|
|
function createDeferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (err: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
class WizardSessionPrompter implements WizardPrompter {
|
|
constructor(private session: WizardSession) {}
|
|
|
|
async intro(title: string): Promise<void> {
|
|
await this.prompt({
|
|
type: "note",
|
|
title,
|
|
message: "",
|
|
executor: "client",
|
|
});
|
|
}
|
|
|
|
async outro(message: string): Promise<void> {
|
|
await this.prompt({
|
|
type: "note",
|
|
title: "Done",
|
|
message,
|
|
executor: "client",
|
|
});
|
|
}
|
|
|
|
async note(message: string, title?: string): Promise<void> {
|
|
await this.prompt({ type: "note", title, message, executor: "client" });
|
|
}
|
|
|
|
async select<T>(params: {
|
|
message: string;
|
|
options: Array<{ value: T; label: string; hint?: string }>;
|
|
initialValue?: T;
|
|
}): Promise<T> {
|
|
const res = await this.prompt({
|
|
type: "select",
|
|
message: params.message,
|
|
options: params.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
hint: opt.hint,
|
|
})),
|
|
initialValue: params.initialValue,
|
|
executor: "client",
|
|
});
|
|
return res as T;
|
|
}
|
|
|
|
async multiselect<T>(params: {
|
|
message: string;
|
|
options: Array<{ value: T; label: string; hint?: string }>;
|
|
initialValues?: T[];
|
|
}): Promise<T[]> {
|
|
const res = await this.prompt({
|
|
type: "multiselect",
|
|
message: params.message,
|
|
options: params.options.map((opt) => ({
|
|
value: opt.value,
|
|
label: opt.label,
|
|
hint: opt.hint,
|
|
})),
|
|
initialValue: params.initialValues,
|
|
executor: "client",
|
|
});
|
|
return (Array.isArray(res) ? res : []) as T[];
|
|
}
|
|
|
|
async text(params: {
|
|
message: string;
|
|
initialValue?: string;
|
|
placeholder?: string;
|
|
validate?: (value: string) => string | undefined;
|
|
}): Promise<string> {
|
|
const res = await this.prompt({
|
|
type: "text",
|
|
message: params.message,
|
|
initialValue: params.initialValue,
|
|
placeholder: params.placeholder,
|
|
executor: "client",
|
|
});
|
|
const value =
|
|
res === null || res === undefined
|
|
? ""
|
|
: typeof res === "string"
|
|
? res
|
|
: typeof res === "number" ||
|
|
typeof res === "boolean" ||
|
|
typeof res === "bigint"
|
|
? String(res)
|
|
: "";
|
|
const error = params.validate?.(value);
|
|
if (error) {
|
|
throw new Error(error);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async confirm(params: {
|
|
message: string;
|
|
initialValue?: boolean;
|
|
}): Promise<boolean> {
|
|
const res = await this.prompt({
|
|
type: "confirm",
|
|
message: params.message,
|
|
initialValue: params.initialValue,
|
|
executor: "client",
|
|
});
|
|
return Boolean(res);
|
|
}
|
|
|
|
progress(_label: string): WizardProgress {
|
|
return {
|
|
update: (_message) => {},
|
|
stop: (_message) => {},
|
|
};
|
|
}
|
|
|
|
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
|
|
return await this.session.awaitAnswer({
|
|
...step,
|
|
id: randomUUID(),
|
|
});
|
|
}
|
|
}
|
|
|
|
export class WizardSession {
|
|
private currentStep: WizardStep | null = null;
|
|
private stepDeferred: Deferred<WizardStep | null> | null = null;
|
|
private answerDeferred = new Map<string, Deferred<unknown>>();
|
|
private status: WizardSessionStatus = "running";
|
|
private error: string | undefined;
|
|
|
|
constructor(private runner: (prompter: WizardPrompter) => Promise<void>) {
|
|
const prompter = new WizardSessionPrompter(this);
|
|
void this.run(prompter);
|
|
}
|
|
|
|
async next(): Promise<WizardNextResult> {
|
|
if (this.currentStep) {
|
|
return { done: false, step: this.currentStep, status: this.status };
|
|
}
|
|
if (this.status !== "running") {
|
|
return { done: true, status: this.status, error: this.error };
|
|
}
|
|
if (!this.stepDeferred) {
|
|
this.stepDeferred = createDeferred();
|
|
}
|
|
const step = await this.stepDeferred.promise;
|
|
if (step) {
|
|
return { done: false, step, status: this.status };
|
|
}
|
|
return { done: true, status: this.status, error: this.error };
|
|
}
|
|
|
|
async answer(stepId: string, value: unknown): Promise<void> {
|
|
const deferred = this.answerDeferred.get(stepId);
|
|
if (!deferred) {
|
|
throw new Error("wizard: no pending step");
|
|
}
|
|
this.answerDeferred.delete(stepId);
|
|
this.currentStep = null;
|
|
deferred.resolve(value);
|
|
}
|
|
|
|
cancel() {
|
|
if (this.status !== "running") return;
|
|
this.status = "cancelled";
|
|
this.error = "cancelled";
|
|
this.currentStep = null;
|
|
for (const [, deferred] of this.answerDeferred) {
|
|
deferred.reject(new WizardCancelledError());
|
|
}
|
|
this.answerDeferred.clear();
|
|
this.resolveStep(null);
|
|
}
|
|
|
|
pushStep(step: WizardStep) {
|
|
this.currentStep = step;
|
|
this.resolveStep(step);
|
|
}
|
|
|
|
private async run(prompter: WizardPrompter) {
|
|
try {
|
|
await this.runner(prompter);
|
|
this.status = "done";
|
|
} catch (err) {
|
|
if (err instanceof WizardCancelledError) {
|
|
this.status = "cancelled";
|
|
this.error = err.message;
|
|
} else {
|
|
this.status = "error";
|
|
this.error = String(err);
|
|
}
|
|
} finally {
|
|
this.resolveStep(null);
|
|
}
|
|
}
|
|
|
|
async awaitAnswer(step: WizardStep): Promise<unknown> {
|
|
if (this.status !== "running") {
|
|
throw new Error("wizard: session not running");
|
|
}
|
|
this.pushStep(step);
|
|
const deferred = createDeferred<unknown>();
|
|
this.answerDeferred.set(step.id, deferred);
|
|
return await deferred.promise;
|
|
}
|
|
|
|
private resolveStep(step: WizardStep | null) {
|
|
if (!this.stepDeferred) return;
|
|
const deferred = this.stepDeferred;
|
|
this.stepDeferred = null;
|
|
deferred.resolve(step);
|
|
}
|
|
|
|
getStatus(): WizardSessionStatus {
|
|
return this.status;
|
|
}
|
|
|
|
getError(): string | undefined {
|
|
return this.error;
|
|
}
|
|
}
|