refactor: deduplicate setup wizard helpers

This commit is contained in:
Peter Steinberger
2026-03-18 03:58:15 +00:00
parent 1c81b82f48
commit 1a9114a169
29 changed files with 1196 additions and 508 deletions

View File

@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from "vitest";
import {
createCliPathTextInput,
createDelegatedSetupWizardStatusResolvers,
createDelegatedTextInputShouldPrompt,
createDetectedBinaryStatus,
} from "./setup-wizard-binary.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
describe("createDetectedBinaryStatus", () => {
it("builds status lines, hint, and score from binary detection", async () => {
const status = createDetectedBinaryStatus({
channelLabel: "Signal",
binaryLabel: "signal-cli",
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "signal-cli found",
unconfiguredHint: "signal-cli missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: () => true,
resolveBinaryPath: () => "/usr/local/bin/signal-cli",
detectBinary: vi.fn(async () => true),
});
expect(await status.resolveConfigured({ cfg: {} })).toBe(true);
expect(await status.resolveStatusLines?.({ cfg: {}, configured: true })).toEqual([
"Signal: configured",
"signal-cli: found (/usr/local/bin/signal-cli)",
]);
expect(await status.resolveSelectionHint?.({ cfg: {}, configured: true })).toBe(
"signal-cli found",
);
expect(await status.resolveQuickstartScore?.({ cfg: {}, configured: true })).toBe(1);
});
});
describe("createCliPathTextInput", () => {
it("reuses the same path resolver for current and initial values", async () => {
const textInput = createCliPathTextInput({
inputKey: "cliPath",
message: "CLI path",
resolvePath: () => "imsg",
shouldPrompt: async () => false,
helpTitle: "iMessage",
helpLines: ["help"],
});
expect(
await textInput.currentValue?.({ cfg: {}, accountId: "default", credentialValues: {} }),
).toBe("imsg");
expect(
await textInput.initialValue?.({ cfg: {}, accountId: "default", credentialValues: {} }),
).toBe("imsg");
expect(textInput.helpTitle).toBe("iMessage");
expect(textInput.helpLines).toEqual(["help"]);
});
});
describe("createDelegatedSetupWizardStatusResolvers", () => {
it("forwards optional status resolvers to the loaded wizard", async () => {
const loadWizard = vi.fn(
async (): Promise<ChannelSetupWizard> => ({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: () => true,
resolveStatusLines: async () => ["line"],
resolveSelectionHint: async () => "hint",
resolveQuickstartScore: async () => 7,
},
credentials: [],
}),
);
const status = createDelegatedSetupWizardStatusResolvers(loadWizard);
expect(await status.resolveStatusLines?.({ cfg: {}, configured: true })).toEqual(["line"]);
expect(await status.resolveSelectionHint?.({ cfg: {}, configured: true })).toBe("hint");
expect(await status.resolveQuickstartScore?.({ cfg: {}, configured: true })).toBe(7);
});
});
describe("createDelegatedTextInputShouldPrompt", () => {
it("forwards shouldPrompt for the requested input key", async () => {
const loadWizard = vi.fn(
async (): Promise<ChannelSetupWizard> => ({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: () => true,
},
credentials: [],
textInputs: [
{
inputKey: "cliPath",
message: "CLI path",
shouldPrompt: async ({ currentValue }) => currentValue !== "imsg",
},
],
}),
);
const shouldPrompt = createDelegatedTextInputShouldPrompt({
loadWizard,
inputKey: "cliPath",
});
expect(
await shouldPrompt({
cfg: {},
accountId: "default",
credentialValues: {},
currentValue: "imsg",
}),
).toBe(false);
expect(
await shouldPrompt({
cfg: {},
accountId: "default",
credentialValues: {},
currentValue: "other",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,100 @@
import type { OpenClawConfig } from "../../config/config.js";
import { detectBinary as defaultDetectBinary } from "../../plugins/setup-binary.js";
import type {
ChannelSetupWizard,
ChannelSetupWizardStatus,
ChannelSetupWizardTextInput,
} from "./setup-wizard.js";
type SetupTextInputParams = Parameters<NonNullable<ChannelSetupWizardTextInput["currentValue"]>>[0];
type SetupStatusParams = Parameters<NonNullable<ChannelSetupWizardStatus["resolveStatusLines"]>>[0];
export function createDetectedBinaryStatus(params: {
channelLabel: string;
binaryLabel: string;
configuredLabel: string;
unconfiguredLabel: string;
configuredHint: string;
unconfiguredHint: string;
configuredScore: number;
unconfiguredScore: number;
resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise<boolean>;
resolveBinaryPath: (params: { cfg: OpenClawConfig }) => string;
detectBinary?: (path: string) => Promise<boolean>;
}): ChannelSetupWizardStatus {
const detectBinary = params.detectBinary ?? defaultDetectBinary;
return {
configuredLabel: params.configuredLabel,
unconfiguredLabel: params.unconfiguredLabel,
configuredHint: params.configuredHint,
unconfiguredHint: params.unconfiguredHint,
configuredScore: params.configuredScore,
unconfiguredScore: params.unconfiguredScore,
resolveConfigured: params.resolveConfigured,
resolveStatusLines: async ({ cfg, configured }: SetupStatusParams) => {
const binaryPath = params.resolveBinaryPath({ cfg });
const detected = await detectBinary(binaryPath);
return [
`${params.channelLabel}: ${configured ? params.configuredLabel : params.unconfiguredLabel}`,
`${params.binaryLabel}: ${detected ? "found" : "missing"} (${binaryPath})`,
];
},
resolveSelectionHint: async ({ cfg }) =>
(await detectBinary(params.resolveBinaryPath({ cfg })))
? params.configuredHint
: params.unconfiguredHint,
resolveQuickstartScore: async ({ cfg }) =>
(await detectBinary(params.resolveBinaryPath({ cfg })))
? params.configuredScore
: params.unconfiguredScore,
};
}
export function createCliPathTextInput(params: {
inputKey: ChannelSetupWizardTextInput["inputKey"];
message: string;
resolvePath: (params: SetupTextInputParams) => string | undefined;
shouldPrompt: NonNullable<ChannelSetupWizardTextInput["shouldPrompt"]>;
helpTitle?: string;
helpLines?: string[];
}): ChannelSetupWizardTextInput {
return {
inputKey: params.inputKey,
message: params.message,
currentValue: params.resolvePath,
initialValue: params.resolvePath,
shouldPrompt: params.shouldPrompt,
confirmCurrentValue: false,
applyCurrentValue: true,
...(params.helpTitle ? { helpTitle: params.helpTitle } : {}),
...(params.helpLines ? { helpLines: params.helpLines } : {}),
};
}
export function createDelegatedSetupWizardStatusResolvers(
loadWizard: () => Promise<ChannelSetupWizard>,
): Pick<
ChannelSetupWizardStatus,
"resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore"
> {
return {
resolveStatusLines: async (params) =>
(await loadWizard()).status.resolveStatusLines?.(params) ?? [],
resolveSelectionHint: async (params) =>
await (await loadWizard()).status.resolveSelectionHint?.(params),
resolveQuickstartScore: async (params) =>
await (await loadWizard()).status.resolveQuickstartScore?.(params),
};
}
export function createDelegatedTextInputShouldPrompt(params: {
loadWizard: () => Promise<ChannelSetupWizard>;
inputKey: ChannelSetupWizardTextInput["inputKey"];
}): NonNullable<ChannelSetupWizardTextInput["shouldPrompt"]> {
return async (inputParams) => {
const input = (await params.loadWizard()).textInputs?.find(
(entry) => entry.inputKey === params.inputKey,
);
return (await input?.shouldPrompt?.(inputParams)) ?? false;
};
}

View File

@@ -6,6 +6,7 @@ import {
buildSingleChannelSecretPromptState,
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createAllowFromSection,
createLegacyCompatChannelDmPolicy,
createNestedChannelAllowFromSetter,
createNestedChannelDmPolicy,
@@ -25,6 +26,7 @@ import {
patchTopLevelChannelConfigSection,
promptLegacyChannelAllowFrom,
promptLegacyChannelAllowFromForAccount,
promptParsedAllowFromForAccount,
parseSetupEntriesWithParser,
promptParsedAllowFromForScopedChannel,
promptSingleChannelSecretInput,
@@ -33,6 +35,7 @@ import {
resolveAccountIdForConfigure,
resolveEntriesWithOptionalToken,
resolveGroupAllowlistWithLookupNotes,
resolveParsedAllowFromEntries,
resolveSetupAccountId,
setAccountDmAllowFromForChannel,
setAccountAllowFromForChannel,
@@ -582,6 +585,76 @@ describe("promptParsedAllowFromForScopedChannel", () => {
});
});
describe("promptParsedAllowFromForAccount", () => {
it("applies parsed allowFrom values through the provided writer", async () => {
const prompter = createPrompter(["Alice, ALICE"]);
const next = await promptParsedAllowFromForAccount({
cfg: {
channels: {
bluebubbles: {
accounts: {
alt: {
allowFrom: ["old"],
},
},
},
},
} as OpenClawConfig,
accountId: "alt",
defaultAccountId: DEFAULT_ACCOUNT_ID,
prompter,
noteTitle: "BlueBubbles allowlist",
noteLines: ["line"],
message: "msg",
placeholder: "placeholder",
parseEntries: (raw) =>
parseSetupEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })),
getExistingAllowFrom: ({ cfg, accountId }) =>
cfg.channels?.bluebubbles?.accounts?.[accountId]?.allowFrom ?? [],
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
patchChannelConfigForAccount({
cfg,
channel: "bluebubbles",
accountId,
patch: { allowFrom },
}),
});
expect(next.channels?.bluebubbles?.accounts?.alt?.allowFrom).toEqual(["alice"]);
expect(prompter.note).toHaveBeenCalledWith("line", "BlueBubbles allowlist");
});
it("can merge parsed values with existing entries", async () => {
const next = await promptParsedAllowFromForAccount({
cfg: {
channels: {
nostr: {
allowFrom: ["old"],
},
},
} as OpenClawConfig,
defaultAccountId: DEFAULT_ACCOUNT_ID,
prompter: createPrompter(["new"]),
noteTitle: "Nostr allowlist",
noteLines: ["line"],
message: "msg",
placeholder: "placeholder",
parseEntries: (raw) => ({ entries: [raw.trim()] }),
getExistingAllowFrom: ({ cfg }) => cfg.channels?.nostr?.allowFrom ?? [],
mergeEntries: ({ existing, parsed }) => [...existing.map(String), ...parsed],
applyAllowFrom: ({ cfg, allowFrom }) =>
patchTopLevelChannelConfigSection({
cfg,
channel: "nostr",
patch: { allowFrom },
}),
});
expect(next.channels?.nostr?.allowFrom).toEqual(["old", "new"]);
});
});
describe("channel lookup note helpers", () => {
it("emits summary lines for resolved and unresolved entries", async () => {
const prompter = { note: vi.fn(async () => undefined) };
@@ -1402,6 +1475,44 @@ describe("createAccountScopedAllowFromSection", () => {
});
});
describe("createAllowFromSection", () => {
it("builds a parsed allowFrom section with default local resolution", async () => {
const section = createAllowFromSection({
helpTitle: "LINE allowlist",
helpLines: ["line"],
credentialInputKey: "token",
message: "LINE allowFrom",
placeholder: "U123",
invalidWithoutCredentialNote: "need ids",
parseId: (value) => value.trim().toUpperCase() || null,
apply: ({ cfg, accountId, allowFrom }) =>
patchChannelConfigForAccount({
cfg,
channel: "line",
accountId,
patch: { dmPolicy: "allowlist", allowFrom },
}),
});
expect(section.helpTitle).toBe("LINE allowlist");
await expect(
section.resolveEntries({
cfg: {},
accountId: DEFAULT_ACCOUNT_ID,
credentialValues: {},
entries: ["u1"],
}),
).resolves.toEqual([{ input: "u1", resolved: true, id: "U1" }]);
const next = await section.apply({
cfg: {},
accountId: DEFAULT_ACCOUNT_ID,
allowFrom: ["U1"],
});
expect(next.channels?.line?.allowFrom).toEqual(["U1"]);
});
});
describe("createAccountScopedGroupAccessSection", () => {
it("builds group access with shared setPolicy and fallback lookup notes", async () => {
const prompter = createPrompter([]);
@@ -1544,6 +1655,20 @@ describe("resolveEntriesWithOptionalToken", () => {
});
});
describe("resolveParsedAllowFromEntries", () => {
it("maps parsed ids into resolved/unresolved entries", () => {
expect(
resolveParsedAllowFromEntries({
entries: ["alice", " "],
parseId: (raw) => raw.trim() || null,
}),
).toEqual([
{ input: "alice", resolved: true, id: "alice" },
{ input: " ", resolved: false, id: null },
]);
});
});
describe("parseMentionOrPrefixedId", () => {
it("parses mention ids", () => {
expect(

View File

@@ -16,7 +16,7 @@ import type {
PromptAccountId,
PromptAccountIdParams,
} from "./setup-wizard-types.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry } from "./setup-wizard.js";
export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => {
const existingIds = params.listAccountIds(params.cfg);
@@ -1051,9 +1051,8 @@ export async function promptSingleChannelSecretInput(params: {
type ParsedAllowFromResult = { entries: string[]; error?: string };
export async function promptParsedAllowFromForScopedChannel(params: {
cfg: OpenClawConfig;
channel: "imessage" | "signal";
export async function promptParsedAllowFromForAccount<TConfig extends OpenClawConfig>(params: {
cfg: TConfig;
accountId?: string;
defaultAccountId: string;
prompter: Pick<WizardPrompter, "note" | "text">;
@@ -1062,11 +1061,14 @@ export async function promptParsedAllowFromForScopedChannel(params: {
message: string;
placeholder: string;
parseEntries: (raw: string) => ParsedAllowFromResult;
getExistingAllowFrom: (params: {
cfg: OpenClawConfig;
getExistingAllowFrom: (params: { cfg: TConfig; accountId: string }) => Array<string | number>;
mergeEntries?: (params: { existing: Array<string | number>; parsed: string[] }) => string[];
applyAllowFrom: (params: {
cfg: TConfig;
accountId: string;
}) => Array<string | number>;
}): Promise<OpenClawConfig> {
allowFrom: string[];
}) => TConfig | Promise<TConfig>;
}): Promise<TConfig> {
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: params.defaultAccountId,
@@ -1089,15 +1091,97 @@ export async function promptParsedAllowFromForScopedChannel(params: {
},
});
const parsed = params.parseEntries(String(entry));
const unique = mergeAllowFromEntries(undefined, parsed.entries);
return setAccountAllowFromForChannel({
const unique =
params.mergeEntries?.({
existing,
parsed: parsed.entries,
}) ?? mergeAllowFromEntries(undefined, parsed.entries);
return await params.applyAllowFrom({
cfg: params.cfg,
channel: params.channel,
accountId,
allowFrom: unique,
});
}
export async function promptParsedAllowFromForScopedChannel(params: {
cfg: OpenClawConfig;
channel: "imessage" | "signal";
accountId?: string;
defaultAccountId: string;
prompter: Pick<WizardPrompter, "note" | "text">;
noteTitle: string;
noteLines: string[];
message: string;
placeholder: string;
parseEntries: (raw: string) => ParsedAllowFromResult;
getExistingAllowFrom: (params: {
cfg: OpenClawConfig;
accountId: string;
}) => Array<string | number>;
}): Promise<OpenClawConfig> {
return await promptParsedAllowFromForAccount({
cfg: params.cfg,
accountId: params.accountId,
defaultAccountId: params.defaultAccountId,
prompter: params.prompter,
noteTitle: params.noteTitle,
noteLines: params.noteLines,
message: params.message,
placeholder: params.placeholder,
parseEntries: params.parseEntries,
getExistingAllowFrom: params.getExistingAllowFrom,
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
setAccountAllowFromForChannel({
cfg,
channel: params.channel,
accountId,
allowFrom,
}),
});
}
export function resolveParsedAllowFromEntries(params: {
entries: string[];
parseId: (raw: string) => string | null;
}): ChannelSetupWizardAllowFromEntry[] {
return params.entries.map((entry) => {
const id = params.parseId(entry);
return {
input: entry,
resolved: Boolean(id),
id,
};
});
}
export function createAllowFromSection(params: {
helpTitle?: string;
helpLines?: string[];
credentialInputKey?: NonNullable<ChannelSetupWizard["allowFrom"]>["credentialInputKey"];
message: string;
placeholder: string;
invalidWithoutCredentialNote: string;
parseInputs?: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseInputs"]>;
parseId: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["parseId"]>;
resolveEntries?: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>;
apply: NonNullable<NonNullable<ChannelSetupWizard["allowFrom"]>["apply"]>;
}): NonNullable<ChannelSetupWizard["allowFrom"]> {
return {
...(params.helpTitle ? { helpTitle: params.helpTitle } : {}),
...(params.helpLines ? { helpLines: params.helpLines } : {}),
...(params.credentialInputKey ? { credentialInputKey: params.credentialInputKey } : {}),
message: params.message,
placeholder: params.placeholder,
invalidWithoutCredentialNote: params.invalidWithoutCredentialNote,
...(params.parseInputs ? { parseInputs: params.parseInputs } : {}),
parseId: params.parseId,
resolveEntries:
params.resolveEntries ??
(async ({ entries }) => resolveParsedAllowFromEntries({ entries, parseId: params.parseId })),
apply: params.apply,
};
}
export async function noteChannelLookupSummary(params: {
prompter: Pick<WizardPrompter, "note">;
label: string;

View File

@@ -0,0 +1,266 @@
import { describe, expect, it, vi } from "vitest";
import {
createAllowlistSetupWizardProxy,
createDelegatedFinalize,
createDelegatedPrepare,
createDelegatedResolveConfigured,
createDelegatedSetupWizardProxy,
} from "./setup-wizard-proxy.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
describe("createDelegatedResolveConfigured", () => {
it("forwards configured resolution to the loaded wizard", async () => {
const loadWizard = vi.fn(
async (): Promise<ChannelSetupWizard> => ({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: async ({ cfg }) => Boolean(cfg.channels?.demo),
},
credentials: [],
}),
);
const resolveConfigured = createDelegatedResolveConfigured(loadWizard);
expect(await resolveConfigured({ cfg: {} })).toBe(false);
expect(await resolveConfigured({ cfg: { channels: { demo: {} } } })).toBe(true);
});
});
describe("createDelegatedPrepare", () => {
it("forwards prepare when the loaded wizard implements it", async () => {
const loadWizard = vi.fn(
async (): Promise<ChannelSetupWizard> => ({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: () => true,
},
credentials: [],
prepare: async ({ cfg }) => ({ cfg: { ...cfg, channels: { demo: { enabled: true } } } }),
}),
);
const prepare = createDelegatedPrepare(loadWizard);
expect(
await prepare({
cfg: {},
accountId: "default",
credentialValues: {},
runtime: {} as never,
prompter: {} as never,
}),
).toEqual({
cfg: {
channels: {
demo: { enabled: true },
},
},
});
});
});
describe("createDelegatedFinalize", () => {
it("forwards finalize when the loaded wizard implements it", async () => {
const loadWizard = vi.fn(
async (): Promise<ChannelSetupWizard> => ({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: () => true,
},
credentials: [],
finalize: async ({ cfg, forceAllowFrom }) => ({
cfg: {
...cfg,
channels: {
demo: { forceAllowFrom },
},
},
}),
}),
);
const finalize = createDelegatedFinalize(loadWizard);
expect(
await finalize({
cfg: {},
accountId: "default",
credentialValues: {},
runtime: {} as never,
prompter: {} as never,
forceAllowFrom: true,
}),
).toEqual({
cfg: {
channels: {
demo: { forceAllowFrom: true },
},
},
});
});
});
describe("createAllowlistSetupWizardProxy", () => {
it("falls back when delegated surfaces are absent", async () => {
const wizard = createAllowlistSetupWizardProxy({
loadWizard: async () =>
({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: () => true,
},
credentials: [],
}) satisfies ChannelSetupWizard,
createBase: ({ promptAllowFrom, resolveAllowFromEntries, resolveGroupAllowlist }) => ({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
resolveConfigured: () => true,
},
credentials: [],
dmPolicy: {
label: "Demo",
channel: "demo" as never,
policyKey: "channels.demo.dmPolicy",
allowFromKey: "channels.demo.allowFrom",
getCurrent: () => "pairing",
setPolicy: (cfg) => cfg,
promptAllowFrom,
},
allowFrom: {
message: "Allow from",
placeholder: "id",
invalidWithoutCredentialNote: "need id",
parseId: () => null,
resolveEntries: resolveAllowFromEntries,
apply: (params) => params.cfg,
},
groupAccess: {
label: "Groups",
placeholder: "group",
currentPolicy: () => "allowlist",
currentEntries: () => [],
updatePrompt: () => false,
setPolicy: (params) => params.cfg,
resolveAllowlist: resolveGroupAllowlist,
},
}),
fallbackResolvedGroupAllowlist: (entries) => entries.map((input) => ({ input })),
});
expect(
await wizard.dmPolicy?.promptAllowFrom?.({
cfg: {},
prompter: {} as never,
accountId: "default",
}),
).toEqual({});
expect(
await wizard.allowFrom?.resolveEntries({
cfg: {},
accountId: "default",
credentialValues: {},
entries: ["alice"],
}),
).toEqual([{ input: "alice", resolved: false, id: null }]);
expect(
await wizard.groupAccess?.resolveAllowlist?.({
cfg: {},
accountId: "default",
credentialValues: {},
entries: ["general"],
prompter: {} as never,
}),
).toEqual([{ input: "general" }]);
});
});
describe("createDelegatedSetupWizardProxy", () => {
it("builds a direct proxy wizard with delegated status/prepare/finalize", async () => {
const wizard = createDelegatedSetupWizardProxy({
channel: "demo",
loadWizard: async () =>
({
channel: "demo",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "ready",
unconfiguredHint: "missing",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: async ({ cfg }) => Boolean(cfg.channels?.demo),
resolveStatusLines: async () => ["line"],
resolveSelectionHint: async () => "hint",
resolveQuickstartScore: async () => 3,
},
credentials: [],
prepare: async ({ cfg }) => ({
cfg: { ...cfg, channels: { demo: { prepared: true } } },
}),
finalize: async ({ cfg }) => ({
cfg: { ...cfg, channels: { demo: { finalized: true } } },
}),
}) satisfies ChannelSetupWizard,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "ready",
unconfiguredHint: "missing",
configuredScore: 1,
unconfiguredScore: 0,
},
credentials: [],
textInputs: [],
completionNote: { title: "Done", lines: ["line"] },
delegatePrepare: true,
delegateFinalize: true,
});
expect(await wizard.status.resolveConfigured({ cfg: {} })).toBe(false);
expect(await wizard.status.resolveStatusLines?.({ cfg: {}, configured: false })).toEqual([
"line",
]);
expect(
await wizard.prepare?.({
cfg: {},
accountId: "default",
credentialValues: {},
runtime: {} as never,
prompter: {} as never,
}),
).toEqual({
cfg: {
channels: {
demo: { prepared: true },
},
},
});
expect(
await wizard.finalize?.({
cfg: {},
accountId: "default",
credentialValues: {},
runtime: {} as never,
prompter: {} as never,
forceAllowFrom: false,
}),
).toEqual({
cfg: {
channels: {
demo: { finalized: true },
},
},
});
});
});

View File

@@ -1,8 +1,10 @@
import type { OpenClawConfig } from "../../config/config.js";
import { createDelegatedSetupWizardStatusResolvers } from "./setup-wizard-binary.js";
import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js";
import type { ChannelSetupWizard } from "./setup-wizard.js";
type PromptAllowFromParams = Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0];
type ResolveConfiguredParams = Parameters<ChannelSetupWizard["status"]["resolveConfigured"]>[0];
type ResolveAllowFromEntriesParams = Parameters<
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
>[0];
@@ -13,6 +15,61 @@ type ResolveGroupAllowlistParams = Parameters<
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
>[0];
export function createDelegatedResolveConfigured(loadWizard: () => Promise<ChannelSetupWizard>) {
return async ({ cfg }: ResolveConfiguredParams) =>
await (await loadWizard()).status.resolveConfigured({ cfg });
}
export function createDelegatedPrepare(loadWizard: () => Promise<ChannelSetupWizard>) {
return async (params: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]) =>
await (await loadWizard()).prepare?.(params);
}
export function createDelegatedFinalize(loadWizard: () => Promise<ChannelSetupWizard>) {
return async (params: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]) =>
await (await loadWizard()).finalize?.(params);
}
type DelegatedStatusBase = Omit<
ChannelSetupWizard["status"],
"resolveConfigured" | "resolveStatusLines" | "resolveSelectionHint" | "resolveQuickstartScore"
>;
export function createDelegatedSetupWizardProxy(params: {
channel: string;
loadWizard: () => Promise<ChannelSetupWizard>;
status: DelegatedStatusBase;
credentials?: ChannelSetupWizard["credentials"];
textInputs?: ChannelSetupWizard["textInputs"];
completionNote?: ChannelSetupWizard["completionNote"];
dmPolicy?: ChannelSetupWizard["dmPolicy"];
disable?: ChannelSetupWizard["disable"];
resolveShouldPromptAccountIds?: ChannelSetupWizard["resolveShouldPromptAccountIds"];
onAccountRecorded?: ChannelSetupWizard["onAccountRecorded"];
delegatePrepare?: boolean;
delegateFinalize?: boolean;
}): ChannelSetupWizard {
return {
channel: params.channel,
status: {
...params.status,
resolveConfigured: createDelegatedResolveConfigured(params.loadWizard),
...createDelegatedSetupWizardStatusResolvers(params.loadWizard),
},
...(params.resolveShouldPromptAccountIds
? { resolveShouldPromptAccountIds: params.resolveShouldPromptAccountIds }
: {}),
...(params.delegatePrepare ? { prepare: createDelegatedPrepare(params.loadWizard) } : {}),
credentials: params.credentials ?? [],
...(params.textInputs ? { textInputs: params.textInputs } : {}),
...(params.delegateFinalize ? { finalize: createDelegatedFinalize(params.loadWizard) } : {}),
...(params.completionNote ? { completionNote: params.completionNote } : {}),
...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
...(params.disable ? { disable: params.disable } : {}),
...(params.onAccountRecorded ? { onAccountRecorded: params.onAccountRecorded } : {}),
} satisfies ChannelSetupWizard;
}
export function createAllowlistSetupWizardProxy<TGroupResolved>(params: {
loadWizard: () => Promise<ChannelSetupWizard>;
createBase: (handlers: {

View File

@@ -110,6 +110,54 @@ describe("createScopedChannelConfigBase", () => {
}).channels,
).toBeUndefined();
});
it("can force default account config into accounts.default", () => {
const base = createScopedChannelConfigBase({
sectionKey: "demo",
listAccountIds: () => ["default", "alt"],
resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }),
defaultAccountId: () => "default",
clearBaseFields: [],
allowTopLevel: false,
});
expect(
base.setAccountEnabled!({
cfg: {
channels: {
demo: {
token: "secret",
},
},
},
accountId: "default",
enabled: true,
}).channels?.demo,
).toEqual({
token: "secret",
accounts: {
default: { enabled: true },
},
});
expect(
base.deleteAccount!({
cfg: {
channels: {
demo: {
token: "secret",
accounts: {
default: { enabled: true },
},
},
},
},
accountId: "default",
}).channels?.demo,
).toEqual({
token: "secret",
accounts: undefined,
});
});
});
describe("createScopedDmSecurityResolver", () => {

View File

@@ -35,6 +35,7 @@ export {
buildSingleChannelSecretPromptState,
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createAllowFromSection,
createLegacyCompatChannelDmPolicy,
createNestedChannelAllowFromSetter,
createNestedChannelDmPolicy,
@@ -55,13 +56,16 @@ export {
patchChannelConfigForAccount,
promptLegacyChannelAllowFrom,
promptLegacyChannelAllowFromForAccount,
promptParsedAllowFromForAccount,
promptParsedAllowFromForScopedChannel,
promptSingleChannelSecretInput,
promptResolvedAllowFrom,
resolveParsedAllowFromEntries,
resolveEntriesWithOptionalToken,
resolveSetupAccountId,
resolveGroupAllowlistWithLookupNotes,
runSingleChannelSecretStep,
setAccountAllowFromForChannel,
setAccountDmAllowFromForChannel,
setAccountGroupPolicyForChannel,
setChannelDmPolicyWithAllowFrom,
@@ -75,5 +79,17 @@ export {
splitSetupEntries,
} from "../channels/plugins/setup-wizard-helpers.js";
export { createAllowlistSetupWizardProxy } from "../channels/plugins/setup-wizard-proxy.js";
export {
createDelegatedFinalize,
createDelegatedPrepare,
createDelegatedResolveConfigured,
createDelegatedSetupWizardProxy,
} from "../channels/plugins/setup-wizard-proxy.js";
export {
createCliPathTextInput,
createDelegatedSetupWizardStatusResolvers,
createDelegatedTextInputShouldPrompt,
createDetectedBinaryStatus,
} from "../channels/plugins/setup-wizard-binary.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";

View File

@@ -99,6 +99,15 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string");
expect(typeof setupSdk.createAccountScopedAllowFromSection).toBe("function");
expect(typeof setupSdk.createAccountScopedGroupAccessSection).toBe("function");
expect(typeof setupSdk.createAllowFromSection).toBe("function");
expect(typeof setupSdk.createCliPathTextInput).toBe("function");
expect(typeof setupSdk.createDelegatedFinalize).toBe("function");
expect(typeof setupSdk.createDelegatedPrepare).toBe("function");
expect(typeof setupSdk.createDelegatedResolveConfigured).toBe("function");
expect(typeof setupSdk.createDelegatedSetupWizardProxy).toBe("function");
expect(typeof setupSdk.createDelegatedSetupWizardStatusResolvers).toBe("function");
expect(typeof setupSdk.createDelegatedTextInputShouldPrompt).toBe("function");
expect(typeof setupSdk.createDetectedBinaryStatus).toBe("function");
expect(typeof setupSdk.createLegacyCompatChannelDmPolicy).toBe("function");
expect(typeof setupSdk.createNestedChannelDmPolicy).toBe("function");
expect(typeof setupSdk.createTopLevelChannelDmPolicy).toBe("function");
@@ -107,7 +116,10 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof setupSdk.mergeAllowFromEntries).toBe("function");
expect(typeof setupSdk.patchNestedChannelConfigSection).toBe("function");
expect(typeof setupSdk.patchTopLevelChannelConfigSection).toBe("function");
expect(typeof setupSdk.promptParsedAllowFromForAccount).toBe("function");
expect(typeof setupSdk.resolveParsedAllowFromEntries).toBe("function");
expect(typeof setupSdk.resolveGroupAllowlistWithLookupNotes).toBe("function");
expect(typeof setupSdk.setAccountAllowFromForChannel).toBe("function");
expect(typeof setupSdk.setAccountDmAllowFromForChannel).toBe("function");
expect(typeof setupSdk.setTopLevelChannelDmPolicyWithAllowFrom).toBe("function");
expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function");