Files
clawdbot/src/wizard/session.ts
2026-01-03 21:41:58 +01:00

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;
}
}