mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
refactor: deduplicate setup wizard helpers
This commit is contained in:
128
src/channels/plugins/setup-wizard-binary.test.ts
Normal file
128
src/channels/plugins/setup-wizard-binary.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
100
src/channels/plugins/setup-wizard-binary.ts
Normal file
100
src/channels/plugins/setup-wizard-binary.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
266
src/channels/plugins/setup-wizard-proxy.test.ts
Normal file
266
src/channels/plugins/setup-wizard-proxy.test.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user