mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 10:11:38 +00:00
* feat(onboarding): add provider sign-in flows * fix(oauth): keep callback compatibility * fix(onboarding): reconcile lost auth outcomes * fix(onboarding): lock auth cancellation at commit * fix(onboarding): close provider auth lifecycle gaps * fix(onboarding): make terminal auth failures dismissable * fix(onboarding): satisfy native app checks * fix(onboarding): reconcile absent auth sessions * fix(onboarding): bound provider auth sessions * fix(onboarding): open provider auth links safely * test(onboarding): use scanner-safe auth fixtures * revert: keep established onboarding auth fixtures * fix(onboarding): close provider auth cancellation gaps * fix(gateway): retain uncollected wizard results * fix(onboarding): bind provider reconciliation attempt * fix(i18n): avoid guessing moved string identities * style(onboarding): normalize remote auth choices efficiently * fix(protocol): refresh optional provider auth choices * test(gateway): cover provider auth dispatch order * refactor(macos): split onboarding setup support * fix(macos): refresh merged native checks
357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
// Wizard session helpers track onboarding session ids and state.
|
|
import { randomUUID } from "node:crypto";
|
|
import { createDeferred, type Deferred } from "../shared/deferred.js";
|
|
import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js";
|
|
|
|
// WizardSession exposes interactive setup as a step/answer protocol for remote
|
|
// clients while reusing the same WizardPrompter contract as the local CLI.
|
|
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;
|
|
format?: "plain";
|
|
options?: WizardStepOption[];
|
|
initialValue?: unknown;
|
|
placeholder?: string;
|
|
sensitive?: boolean;
|
|
executor?: "gateway" | "client";
|
|
externalUrl?: string;
|
|
};
|
|
|
|
type WizardSessionStatus = "running" | "done" | "cancelled" | "error";
|
|
|
|
type WizardNextResult = {
|
|
done: boolean;
|
|
step?: WizardStep;
|
|
status: WizardSessionStatus;
|
|
error?: string;
|
|
};
|
|
|
|
function normalizeTextAnswer(value: unknown): string | undefined {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
return String(value);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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 plain(message: string): Promise<void> {
|
|
await this.prompt({ type: "note", message, format: "plain", 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;
|
|
sensitive?: boolean;
|
|
}): Promise<string> {
|
|
const res = await this.session.awaitAnswer(
|
|
this.createStep({
|
|
type: "text",
|
|
message: params.message,
|
|
initialValue: params.initialValue,
|
|
placeholder: params.placeholder,
|
|
sensitive: params.sensitive,
|
|
executor: "client",
|
|
}),
|
|
params.validate,
|
|
);
|
|
const value =
|
|
res === null || res === undefined
|
|
? ""
|
|
: typeof res === "string"
|
|
? res
|
|
: typeof res === "number" || typeof res === "boolean" || typeof res === "bigint"
|
|
? String(res)
|
|
: "";
|
|
return value;
|
|
}
|
|
|
|
async confirm(params: Parameters<WizardPrompter["confirm"]>[0]): 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) => {},
|
|
};
|
|
}
|
|
|
|
async openUrl(url: string): Promise<void> {
|
|
this.session.queueExternalUrl(url);
|
|
}
|
|
|
|
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
|
|
return await this.session.awaitAnswer(this.createStep(step));
|
|
}
|
|
|
|
private createStep(step: Omit<WizardStep, "id">): WizardStep {
|
|
// Each emitted step receives an id so remote clients can answer the exact
|
|
// pending prompt and stale answers can be rejected. Explicit browser
|
|
// destinations bind to the very next step regardless of its input type.
|
|
const externalUrl = this.session.consumeExternalUrl();
|
|
return {
|
|
...step,
|
|
...(externalUrl ? { externalUrl } : {}),
|
|
id: randomUUID(),
|
|
};
|
|
}
|
|
}
|
|
|
|
export class WizardSession {
|
|
private readonly abortController = new AbortController();
|
|
private readonly expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
private currentStep: WizardStep | null = null;
|
|
private stepDeferred: Deferred<WizardStep | null> | null = null;
|
|
private pendingTerminalResolution = false;
|
|
private cancellationLocked = false;
|
|
private pendingExternalUrl: string | undefined;
|
|
private answerDeferred = new Map<
|
|
string,
|
|
{
|
|
deferred: Deferred<unknown>;
|
|
text: boolean;
|
|
validate?: (value: string) => string | undefined;
|
|
}
|
|
>();
|
|
private status: WizardSessionStatus = "running";
|
|
private error: string | undefined;
|
|
|
|
constructor(
|
|
private runner: (prompter: WizardPrompter, signal: AbortSignal) => Promise<void>,
|
|
options?: { timeoutMs?: number },
|
|
) {
|
|
const prompter = new WizardSessionPrompter(this);
|
|
if (options?.timeoutMs !== undefined) {
|
|
this.expiryTimer = setTimeout(() => this.cancel(), options.timeoutMs);
|
|
this.expiryTimer.unref?.();
|
|
}
|
|
void this.run(prompter);
|
|
}
|
|
|
|
async next(): Promise<WizardNextResult> {
|
|
if (this.currentStep) {
|
|
return { done: false, step: this.currentStep, status: this.status };
|
|
}
|
|
if (this.pendingTerminalResolution) {
|
|
this.pendingTerminalResolution = false;
|
|
return { done: true, status: this.status, error: this.error };
|
|
}
|
|
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<string | undefined> {
|
|
const pending = this.answerDeferred.get(stepId);
|
|
if (!pending) {
|
|
throw new Error("wizard: no pending step");
|
|
}
|
|
const normalizedValue = pending.text ? normalizeTextAnswer(value) : value;
|
|
if (pending.text && normalizedValue === undefined) {
|
|
return "wizard: text answer must be a scalar value";
|
|
}
|
|
const validationError = pending.validate?.(normalizedValue as string) ?? undefined;
|
|
if (validationError) {
|
|
return validationError;
|
|
}
|
|
this.answerDeferred.delete(stepId);
|
|
this.currentStep = null;
|
|
pending.deferred.resolve(normalizedValue);
|
|
return undefined;
|
|
}
|
|
|
|
cancel(): boolean {
|
|
if (this.status !== "running" || this.cancellationLocked) {
|
|
return false;
|
|
}
|
|
this.status = "cancelled";
|
|
this.error = "cancelled";
|
|
this.abortController.abort(new WizardCancelledError());
|
|
this.currentStep = null;
|
|
for (const [, pending] of this.answerDeferred) {
|
|
// Reject all pending prompt promises so the runner can unwind through its
|
|
// normal cancellation path.
|
|
pending.deferred.reject(new WizardCancelledError());
|
|
}
|
|
this.answerDeferred.clear();
|
|
this.resolveStep(null);
|
|
return true;
|
|
}
|
|
|
|
/** The underlying mutation crossed its durable commit point and must finish. */
|
|
lockCancellation() {
|
|
this.cancellationLocked = true;
|
|
}
|
|
|
|
get signal(): AbortSignal {
|
|
return this.abortController.signal;
|
|
}
|
|
|
|
pushStep(step: WizardStep) {
|
|
this.currentStep = step;
|
|
this.resolveStep(step);
|
|
}
|
|
|
|
queueExternalUrl(url: string) {
|
|
this.pendingExternalUrl = url;
|
|
}
|
|
|
|
consumeExternalUrl(): string | undefined {
|
|
const url = this.pendingExternalUrl;
|
|
this.pendingExternalUrl = undefined;
|
|
return url;
|
|
}
|
|
|
|
private async run(prompter: WizardPrompter) {
|
|
try {
|
|
await this.runner(prompter, this.signal);
|
|
if (this.status === "running") {
|
|
this.status = "done";
|
|
}
|
|
} catch (err) {
|
|
if (this.status !== "running") {
|
|
return;
|
|
}
|
|
if (err instanceof WizardCancelledError) {
|
|
this.status = "cancelled";
|
|
this.error = err.message;
|
|
} else {
|
|
this.status = "error";
|
|
this.error = String(err);
|
|
}
|
|
} finally {
|
|
if (this.expiryTimer) {
|
|
clearTimeout(this.expiryTimer);
|
|
}
|
|
this.resolveStep(null);
|
|
}
|
|
}
|
|
|
|
async awaitAnswer(
|
|
step: WizardStep,
|
|
validate?: (value: string) => string | undefined,
|
|
): 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, text: step.type === "text", validate });
|
|
return await deferred.promise;
|
|
}
|
|
|
|
private resolveStep(step: WizardStep | null) {
|
|
if (!this.stepDeferred) {
|
|
if (step === null) {
|
|
// The runner can finish immediately after an answer before next() has
|
|
// installed a waiter; remember that terminal state for the next poll.
|
|
this.pendingTerminalResolution = true;
|
|
}
|
|
return;
|
|
}
|
|
const deferred = this.stepDeferred;
|
|
this.stepDeferred = null;
|
|
deferred.resolve(step);
|
|
}
|
|
|
|
getStatus(): WizardSessionStatus {
|
|
return this.status;
|
|
}
|
|
|
|
getError(): string | undefined {
|
|
return this.error;
|
|
}
|
|
}
|