mirror of https://github.com/openclaw/openclaw.git
Channels: run post-write setup hooks
This commit is contained in:
parent
81e6c19474
commit
cba14062f6
|
|
@ -0,0 +1,220 @@
|
|||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const verificationMocks = vi.hoisted(() => ({
|
||||
bootstrapMatrixVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
bootstrapMatrixVerification: verificationMocks.bootstrapMatrixVerification,
|
||||
}));
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("matrix setup post-write bootstrap", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log,
|
||||
error,
|
||||
exit,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
verificationMocks.bootstrapMatrixVerification.mockReset();
|
||||
log.mockClear();
|
||||
error.mockClear();
|
||||
exit.mockClear();
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("bootstraps verification for newly added encrypted accounts", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "7",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not bootstrap verification for already configured accounts", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
flurry: {
|
||||
encryption: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "new-token",
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "flurry",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "flurry",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a warning when verification bootstrap fails", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: false,
|
||||
error: "no room-key backup exists on the homeserver",
|
||||
verification: {
|
||||
backupVersion: null,
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Matrix verification bootstrap warning for "default": no room-key backup exists on the homeserver',
|
||||
);
|
||||
});
|
||||
|
||||
it("bootstraps a newly added env-backed default account when encryption is already enabled", async () => {
|
||||
const previousEnv = {
|
||||
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
|
||||
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
|
||||
};
|
||||
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
process.env.MATRIX_ACCESS_TOKEN = "env-token";
|
||||
try {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
useEnv: true,
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "9",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
import { matrixOnboardingAdapter } from "./onboarding.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
|
|
@ -392,6 +393,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||
initialSyncLimit: input.initialSyncLimit,
|
||||
});
|
||||
},
|
||||
afterAccountConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => {
|
||||
await runMatrixSetupBootstrapAfterConfigWrite({
|
||||
previousCfg: previousCfg as CoreConfig,
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
runtime,
|
||||
});
|
||||
},
|
||||
},
|
||||
outbound: matrixOutbound,
|
||||
status: {
|
||||
|
|
|
|||
|
|
@ -435,8 +435,17 @@ describe("matrix CLI verification commands", () => {
|
|||
});
|
||||
|
||||
it("does not bootstrap verification when updating an already configured account", async () => {
|
||||
resolveMatrixAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: true,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from "./matrix/direct-management.js";
|
||||
import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { maybeBootstrapNewEncryptedMatrixAccount } from "./setup-bootstrap.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
let matrixCliExitScheduled = false;
|
||||
|
|
@ -193,8 +194,6 @@ async function addMatrixAccount(params: {
|
|||
accountId: params.account,
|
||||
input,
|
||||
}) ?? normalizeAccountId(params.account?.trim() || params.name?.trim());
|
||||
const existingAccount = resolveMatrixAccount({ cfg, accountId });
|
||||
|
||||
const validationError = setup.validateInput?.({
|
||||
cfg,
|
||||
accountId,
|
||||
|
|
@ -218,27 +217,12 @@ async function addMatrixAccount(params: {
|
|||
recoveryKeyCreatedAt: null,
|
||||
backupVersion: null,
|
||||
};
|
||||
if (existingAccount.configured !== true && accountConfig.encryption === true) {
|
||||
try {
|
||||
const bootstrap = await bootstrapMatrixVerification({ accountId });
|
||||
verificationBootstrap = {
|
||||
attempted: true,
|
||||
success: bootstrap.success === true,
|
||||
recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt,
|
||||
backupVersion: bootstrap.verification.backupVersion,
|
||||
...(bootstrap.success
|
||||
? {}
|
||||
: { error: bootstrap.error ?? "Matrix verification bootstrap failed" }),
|
||||
};
|
||||
} catch (err) {
|
||||
verificationBootstrap = {
|
||||
attempted: true,
|
||||
success: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
backupVersion: null,
|
||||
error: toErrorMessage(err),
|
||||
};
|
||||
}
|
||||
if (accountConfig.encryption === true) {
|
||||
verificationBootstrap = await maybeBootstrapNewEncryptedMatrixAccount({
|
||||
previousCfg: cfg,
|
||||
cfg: updated,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const desiredDisplayName = input.name?.trim();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
|
||||
|
|
@ -43,3 +44,25 @@ export function findMatrixAccountConfig(
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (findMatrixAccountConfig(cfg, normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (normalized !== DEFAULT_ACCOUNT_ID) {
|
||||
return false;
|
||||
}
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
return (
|
||||
typeof matrix.enabled === "boolean" ||
|
||||
typeof matrix.name === "string" ||
|
||||
typeof matrix.homeserver === "string" ||
|
||||
typeof matrix.userId === "string" ||
|
||||
typeof matrix.accessToken === "string" ||
|
||||
typeof matrix.password === "string" ||
|
||||
typeof matrix.deviceId === "string" ||
|
||||
typeof matrix.deviceName === "string" ||
|
||||
typeof matrix.avatarUrl === "string"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from "./matrix/config-update.js";
|
||||
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
|
|
@ -578,6 +579,14 @@ export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
|||
intent: action === "add-account" ? "add-account" : "update",
|
||||
});
|
||||
},
|
||||
afterConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => {
|
||||
await runMatrixSetupBootstrapAfterConfigWrite({
|
||||
previousCfg: previousCfg as CoreConfig,
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
runtime,
|
||||
});
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...(cfg as CoreConfig),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { hasExplicitMatrixAccountConfig } from "./matrix/account-config.js";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { bootstrapMatrixVerification } from "./matrix/actions/verification.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export type MatrixSetupVerificationBootstrapResult = {
|
||||
attempted: boolean;
|
||||
success: boolean;
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
backupVersion: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function maybeBootstrapNewEncryptedMatrixAccount(params: {
|
||||
previousCfg: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
}): Promise<MatrixSetupVerificationBootstrapResult> {
|
||||
const accountConfig = resolveMatrixAccountConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
if (
|
||||
hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) ||
|
||||
accountConfig.encryption !== true
|
||||
) {
|
||||
return {
|
||||
attempted: false,
|
||||
success: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
backupVersion: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const bootstrap = await bootstrapMatrixVerification({ accountId: params.accountId });
|
||||
return {
|
||||
attempted: true,
|
||||
success: bootstrap.success === true,
|
||||
recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt,
|
||||
backupVersion: bootstrap.verification.backupVersion,
|
||||
...(bootstrap.success
|
||||
? {}
|
||||
: { error: bootstrap.error ?? "Matrix verification bootstrap failed" }),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
attempted: true,
|
||||
success: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
backupVersion: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMatrixSetupBootstrapAfterConfigWrite(params: {
|
||||
previousCfg: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
const nextAccountConfig = resolveMatrixAccountConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (nextAccountConfig.encryption !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrap = await maybeBootstrapNewEncryptedMatrixAccount({
|
||||
previousCfg: params.previousCfg,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!bootstrap.attempted) {
|
||||
return;
|
||||
}
|
||||
if (bootstrap.success) {
|
||||
params.runtime.log(`Matrix verification bootstrap: complete for "${params.accountId}".`);
|
||||
if (bootstrap.backupVersion) {
|
||||
params.runtime.log(
|
||||
`Matrix backup version for "${params.accountId}": ${bootstrap.backupVersion}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
params.runtime.error(
|
||||
`Matrix verification bootstrap warning for "${params.accountId}": ${bootstrap.error ?? "unknown bootstrap failure"}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export type SetupChannelsOptions = {
|
|||
allowDisable?: boolean;
|
||||
allowSignalInstall?: boolean;
|
||||
onSelection?: (selection: ChannelId[]) => void;
|
||||
onPostWriteHook?: (hook: ChannelOnboardingPostWriteHook) => void;
|
||||
accountIds?: Partial<Record<ChannelId, string>>;
|
||||
onAccountId?: (channel: ChannelId, accountId: string) => void;
|
||||
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void;
|
||||
|
|
@ -64,6 +65,19 @@ export type ChannelSetupConfigureContext = {
|
|||
forceAllowFrom: boolean;
|
||||
};
|
||||
|
||||
export type ChannelOnboardingPostWriteContext = {
|
||||
previousCfg: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
};
|
||||
|
||||
export type ChannelOnboardingPostWriteHook = {
|
||||
channel: ChannelId;
|
||||
accountId: string;
|
||||
run: (ctx: { cfg: OpenClawConfig; runtime: RuntimeEnv }) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ChannelSetupResult = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
|
|
@ -104,6 +118,7 @@ export type ChannelSetupWizardAdapter = {
|
|||
configureWhenConfigured?: (
|
||||
ctx: ChannelSetupInteractiveContext,
|
||||
) => Promise<ChannelSetupConfiguredResult>;
|
||||
afterConfigWritten?: (ctx: ChannelOnboardingPostWriteContext) => Promise<void> | void;
|
||||
dmPolicy?: ChannelSetupDmPolicy;
|
||||
onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void;
|
||||
disable?: (cfg: OpenClawConfig) => OpenClawConfig;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ export type ChannelSetupAdapter = {
|
|||
accountId: string;
|
||||
input: ChannelSetupInput;
|
||||
}) => OpenClawConfig;
|
||||
afterAccountConfigWritten?: (params: {
|
||||
previousCfg: OpenClawConfig;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
input: ChannelSetupInput;
|
||||
runtime: RuntimeEnv;
|
||||
}) => Promise<void> | void;
|
||||
validateInput?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-hel
|
|||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const setupChannelsMock = vi.hoisted(() => vi.fn());
|
||||
const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
const wizardMocks = vi.hoisted(() => ({
|
||||
createClackPrompter: vi.fn(),
|
||||
|
|
@ -18,6 +20,16 @@ vi.mock("../wizard/clack-prompter.js", () => ({
|
|||
createClackPrompter: wizardMocks.createClackPrompter,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-channels.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./onboard-channels.js")>()),
|
||||
setupChannels: setupChannelsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
|
||||
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
|
||||
}));
|
||||
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import { agentsAddCommand } from "./agents.js";
|
||||
|
||||
|
|
@ -27,6 +39,8 @@ describe("agents add command", () => {
|
|||
beforeEach(() => {
|
||||
readConfigFileSnapshotMock.mockClear();
|
||||
writeConfigFileMock.mockClear();
|
||||
setupChannelsMock.mockReset();
|
||||
ensureWorkspaceAndSessionsMock.mockClear();
|
||||
wizardMocks.createClackPrompter.mockClear();
|
||||
runtime.log.mockClear();
|
||||
runtime.error.mockClear();
|
||||
|
|
@ -70,4 +84,61 @@ describe("agents add command", () => {
|
|||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(writeConfigFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs collected channel post-write hooks after saving the agent config", async () => {
|
||||
const hookRun = vi.fn().mockResolvedValue(undefined);
|
||||
readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
setupChannelsMock.mockImplementation(async (cfg, _runtime, _prompter, options) => {
|
||||
options?.onPostWriteHook?.({
|
||||
channel: "telegram",
|
||||
accountId: "acct-1",
|
||||
run: hookRun,
|
||||
});
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: {
|
||||
botToken: "new-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
wizardMocks.createClackPrompter.mockReturnValue({
|
||||
intro: vi.fn().mockResolvedValue(undefined),
|
||||
text: vi.fn().mockResolvedValue("/tmp/work"),
|
||||
confirm: vi.fn().mockResolvedValue(false),
|
||||
note: vi.fn().mockResolvedValue(undefined),
|
||||
outro: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await agentsAddCommand({ name: "Work" }, runtime);
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agents: expect.objectContaining({
|
||||
list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "work",
|
||||
name: "Work",
|
||||
workspace: "/tmp/work",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(hookRun).toHaveBeenCalledWith({
|
||||
cfg: expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "new-token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
});
|
||||
expect(writeConfigFileMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hookRun.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.
|
|||
import { applyAgentConfig, findAgentEntryIndex, listAgentEntries } from "./agents.config.js";
|
||||
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
|
||||
import { applyAuthChoice, warnIfModelConfigLooksOff } from "./auth-choice.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
createChannelOnboardingPostWriteHookCollector,
|
||||
runCollectedChannelOnboardingPostWriteHooks,
|
||||
setupChannels,
|
||||
} from "./onboard-channels.js";
|
||||
import { ensureWorkspaceAndSessions } from "./onboard-helpers.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
|
||||
|
|
@ -294,8 +298,12 @@ export async function agentsAddCommand(
|
|||
|
||||
let selection: ChannelChoice[] = [];
|
||||
const channelAccountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
|
||||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowSignalInstall: true,
|
||||
onPostWriteHook: (hook) => {
|
||||
postWriteHooks.collect(hook);
|
||||
},
|
||||
onSelection: (value) => {
|
||||
selection = value;
|
||||
},
|
||||
|
|
@ -343,6 +351,11 @@ export async function agentsAddCommand(
|
|||
}
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: postWriteHooks.drain(),
|
||||
cfg: nextConfig,
|
||||
runtime,
|
||||
});
|
||||
logConfigUpdated(runtime);
|
||||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ import type { ChannelChoice } from "./onboard-types.js";
|
|||
type ChannelSetupWizardAdapterPatch = Partial<
|
||||
Pick<
|
||||
ChannelSetupWizardAdapter,
|
||||
"configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus"
|
||||
"afterConfigWritten" | "configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus"
|
||||
>
|
||||
>;
|
||||
|
||||
type PatchedSetupAdapterFields = {
|
||||
afterConfigWritten?: ChannelSetupWizardAdapter["afterConfigWritten"];
|
||||
configure?: ChannelSetupWizardAdapter["configure"];
|
||||
configureInteractive?: ChannelSetupWizardAdapter["configureInteractive"];
|
||||
configureWhenConfigured?: ChannelSetupWizardAdapter["configureWhenConfigured"];
|
||||
|
|
@ -47,6 +48,10 @@ export function patchChannelSetupWizardAdapter(
|
|||
previous.getStatus = adapter.getStatus;
|
||||
adapter.getStatus = patch.getStatus ?? adapter.getStatus;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
|
||||
previous.afterConfigWritten = adapter.afterConfigWritten;
|
||||
adapter.afterConfigWritten = patch.afterConfigWritten;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
|
||||
previous.configure = adapter.configure;
|
||||
adapter.configure = patch.configure ?? adapter.configure;
|
||||
|
|
@ -64,6 +69,9 @@ export function patchChannelSetupWizardAdapter(
|
|||
if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) {
|
||||
adapter.getStatus = previous.getStatus!;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) {
|
||||
adapter.afterConfigWritten = previous.afterConfigWritten;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "configure")) {
|
||||
adapter.configure = previous.configure!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
|
|
@ -339,4 +340,106 @@ describe("channelsAddCommand", () => {
|
|||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs post-setup hooks after writing config", async () => {
|
||||
const afterAccountConfigWritten = vi.fn().mockResolvedValue(undefined);
|
||||
const plugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "signal",
|
||||
label: "Signal",
|
||||
}),
|
||||
setup: {
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
signal: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
signalNumber: input.signalNumber,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
afterAccountConfigWritten,
|
||||
},
|
||||
} as ChannelPlugin;
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: "signal", plugin, source: "test" }]));
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
|
||||
await channelsAddCommand(
|
||||
{ channel: "signal", account: "ops", signalNumber: "+15550001" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(afterAccountConfigWritten).toHaveBeenCalledTimes(1);
|
||||
expect(configMocks.writeConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
afterAccountConfigWritten.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expect(afterAccountConfigWritten).toHaveBeenCalledWith({
|
||||
previousCfg: baseConfigSnapshot.config,
|
||||
cfg: expect.objectContaining({
|
||||
channels: {
|
||||
signal: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
ops: {
|
||||
signalNumber: "+15550001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
accountId: "ops",
|
||||
input: expect.objectContaining({
|
||||
signalNumber: "+15550001",
|
||||
}),
|
||||
runtime,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the saved config when a post-setup hook fails", async () => {
|
||||
const afterAccountConfigWritten = vi.fn().mockRejectedValue(new Error("hook failed"));
|
||||
const plugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "signal",
|
||||
label: "Signal",
|
||||
}),
|
||||
setup: {
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
signal: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
signalNumber: input.signalNumber,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
afterAccountConfigWritten,
|
||||
},
|
||||
} as ChannelPlugin;
|
||||
setActivePluginRegistry(createTestRegistry([{ pluginId: "signal", plugin, source: "test" }]));
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
|
||||
await channelsAddCommand(
|
||||
{ channel: "signal", account: "ops", signalNumber: "+15550001" },
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Channel signal post-setup warning for "ops": hook failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
|||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
|
||||
import { isCatalogChannelInstalled } from "../channel-setup/discovery.js";
|
||||
import {
|
||||
createChannelOnboardingPostWriteHookCollector,
|
||||
runCollectedChannelOnboardingPostWriteHooks,
|
||||
} from "../onboard-channels.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||
|
|
@ -55,6 +59,7 @@ export async function channelsAddCommand(
|
|||
import("../onboard-channels.js"),
|
||||
]);
|
||||
const prompter = createClackPrompter();
|
||||
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
|
||||
let selection: ChannelChoice[] = [];
|
||||
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
const resolvedPlugins = new Map<ChannelChoice, ChannelSetupPlugin>();
|
||||
|
|
@ -62,6 +67,9 @@ export async function channelsAddCommand(
|
|||
let nextConfig = await setupChannels(cfg, runtime, prompter, {
|
||||
allowDisable: false,
|
||||
allowSignalInstall: true,
|
||||
onPostWriteHook: (hook) => {
|
||||
postWriteHooks.collect(hook);
|
||||
},
|
||||
promptAccountIds: true,
|
||||
onSelection: (value) => {
|
||||
selection = value;
|
||||
|
|
@ -170,6 +178,11 @@ export async function channelsAddCommand(
|
|||
}
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: postWriteHooks.drain(),
|
||||
cfg: nextConfig,
|
||||
runtime,
|
||||
});
|
||||
await prompter.outro("Channels updated.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -337,4 +350,24 @@ export async function channelsAddCommand(
|
|||
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
|
||||
if (plugin.setup.afterAccountConfigWritten) {
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: [
|
||||
{
|
||||
channel,
|
||||
accountId,
|
||||
run: async ({ cfg: writtenCfg, runtime: hookRuntime }) =>
|
||||
await plugin.setup.afterAccountConfigWritten?.({
|
||||
previousCfg: cfg,
|
||||
cfg: writtenCfg,
|
||||
accountId,
|
||||
input,
|
||||
runtime: hookRuntime,
|
||||
}),
|
||||
},
|
||||
],
|
||||
cfg: nextConfig,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => ({
|
|||
waitForGatewayReachable: vi.fn(),
|
||||
resolveControlUiLinks: vi.fn(),
|
||||
summarizeExistingConfig: vi.fn(),
|
||||
noteChannelStatus: vi.fn(),
|
||||
setupChannels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
|
|
@ -91,8 +93,10 @@ vi.mock("./onboard-skills.js", () => ({
|
|||
setupSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-channels.js", () => ({
|
||||
setupChannels: vi.fn(),
|
||||
vi.mock("./onboard-channels.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./onboard-channels.js")>()),
|
||||
noteChannelStatus: mocks.noteChannelStatus,
|
||||
setupChannels: mocks.setupChannels,
|
||||
}));
|
||||
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
|
|
@ -100,6 +104,8 @@ import { runConfigureWizard } from "./configure.wizard.js";
|
|||
|
||||
describe("runConfigureWizard", () => {
|
||||
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||
mocks.noteChannelStatus.mockReset();
|
||||
mocks.setupChannels.mockReset();
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
|
|
@ -110,6 +116,7 @@ describe("runConfigureWizard", () => {
|
|||
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
|
||||
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.summarizeExistingConfig.mockReturnValue("");
|
||||
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
|
||||
mocks.createClackPrompter.mockReturnValue({});
|
||||
|
||||
const selectQueue = ["local", "__continue"];
|
||||
|
|
@ -136,6 +143,8 @@ describe("runConfigureWizard", () => {
|
|||
});
|
||||
|
||||
it("exits with code 1 when configure wizard is cancelled", async () => {
|
||||
mocks.noteChannelStatus.mockReset();
|
||||
mocks.setupChannels.mockReset();
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
|
|
@ -158,4 +167,75 @@ describe("runConfigureWizard", () => {
|
|||
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("runs channel post-write hooks after persisting channel changes", async () => {
|
||||
const hookRun = vi.fn().mockResolvedValue(undefined);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
mocks.noteChannelStatus.mockReset();
|
||||
mocks.noteChannelStatus.mockResolvedValue(undefined);
|
||||
mocks.setupChannels.mockReset();
|
||||
mocks.setupChannels.mockImplementation(async (cfg, _runtime, _prompter, options) => {
|
||||
options?.onPostWriteHook?.({
|
||||
channel: "telegram",
|
||||
accountId: "acct-1",
|
||||
run: hookRun,
|
||||
});
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: { botToken: "new-token" },
|
||||
},
|
||||
};
|
||||
});
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
issues: [],
|
||||
});
|
||||
mocks.resolveGatewayPort.mockReturnValue(18789);
|
||||
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
|
||||
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
|
||||
mocks.summarizeExistingConfig.mockReturnValue("");
|
||||
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
|
||||
mocks.createClackPrompter.mockReturnValue({});
|
||||
const selectQueue = ["local", "configure"];
|
||||
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
|
||||
mocks.clackIntro.mockResolvedValue(undefined);
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
mocks.clackText.mockResolvedValue("");
|
||||
mocks.clackConfirm.mockResolvedValue(false);
|
||||
|
||||
await runConfigureWizard({ command: "configure", sections: ["channels"] }, runtime);
|
||||
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gateway: expect.objectContaining({ mode: "local" }),
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "new-token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(hookRun).toHaveBeenCalledWith({
|
||||
cfg: expect.objectContaining({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "new-token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
runtime,
|
||||
});
|
||||
expect(mocks.writeConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hookRun.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,12 @@ import {
|
|||
} from "./configure.shared.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import { noteChannelStatus, setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
createChannelOnboardingPostWriteHookCollector,
|
||||
noteChannelStatus,
|
||||
runCollectedChannelOnboardingPostWriteHooks,
|
||||
setupChannels,
|
||||
} from "./onboard-channels.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
DEFAULT_WORKSPACE,
|
||||
|
|
@ -429,6 +434,7 @@ export async function runConfigureWizard(
|
|||
baseConfig.agents?.defaults?.workspace ??
|
||||
DEFAULT_WORKSPACE;
|
||||
let gatewayPort = resolveGatewayPort(baseConfig);
|
||||
const postWriteHooks = createChannelOnboardingPostWriteHookCollector();
|
||||
|
||||
const persistConfig = async () => {
|
||||
nextConfig = applyWizardMetadata(nextConfig, {
|
||||
|
|
@ -436,6 +442,11 @@ export async function runConfigureWizard(
|
|||
mode,
|
||||
});
|
||||
await writeConfigFile(nextConfig);
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: postWriteHooks.drain(),
|
||||
cfg: nextConfig,
|
||||
runtime,
|
||||
});
|
||||
logConfigUpdated(runtime);
|
||||
};
|
||||
|
||||
|
|
@ -494,6 +505,9 @@ export async function runConfigureWizard(
|
|||
nextConfig = await setupChannels(nextConfig, runtime, prompter, {
|
||||
allowDisable: true,
|
||||
allowSignalInstall: true,
|
||||
onPostWriteHook: (hook) => {
|
||||
postWriteHooks.collect(hook);
|
||||
},
|
||||
skipConfirm: true,
|
||||
skipStatusNote: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import {
|
||||
patchChannelOnboardingAdapter,
|
||||
setDefaultChannelPluginRegistryForTests,
|
||||
} from "./channel-test-helpers.js";
|
||||
import {
|
||||
createChannelOnboardingPostWriteHookCollector,
|
||||
runCollectedChannelOnboardingPostWriteHooks,
|
||||
setupChannels,
|
||||
} from "./onboard-channels.js";
|
||||
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
|
||||
|
||||
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
||||
return createWizardPrompter(
|
||||
{
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
...overrides,
|
||||
},
|
||||
{ defaultSelect: "__done__" },
|
||||
);
|
||||
}
|
||||
|
||||
function createQuickstartTelegramSelect() {
|
||||
return vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Select channel (QuickStart)") {
|
||||
return "telegram";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
}
|
||||
|
||||
function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) {
|
||||
return createPrompter({
|
||||
select,
|
||||
multiselect: vi.fn(async () => {
|
||||
throw new Error("unexpected multiselect");
|
||||
}),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
throw new Error(`unexpected text prompt: ${message}`);
|
||||
}) as unknown as WizardPrompter["text"],
|
||||
});
|
||||
}
|
||||
|
||||
describe("setupChannels post-write hooks", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
it("collects onboarding post-write hooks and runs them against the final config", async () => {
|
||||
const select = createQuickstartTelegramSelect();
|
||||
const afterConfigWritten = vi.fn(async () => {});
|
||||
const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
cfg: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
telegram: { ...cfg.channels?.telegram, botToken: "new-token" },
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "acct-1",
|
||||
}));
|
||||
const restore = patchChannelOnboardingAdapter("telegram", {
|
||||
configureInteractive,
|
||||
afterConfigWritten,
|
||||
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
channel: "telegram",
|
||||
configured: Boolean(cfg.channels?.telegram?.botToken),
|
||||
statusLines: [],
|
||||
})),
|
||||
});
|
||||
const prompter = createUnexpectedQuickstartPrompter(
|
||||
select as unknown as WizardPrompter["select"],
|
||||
);
|
||||
const collector = createChannelOnboardingPostWriteHookCollector();
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
try {
|
||||
const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
skipConfirm: true,
|
||||
onPostWriteHook: (hook) => {
|
||||
collector.collect(hook);
|
||||
},
|
||||
});
|
||||
|
||||
expect(afterConfigWritten).not.toHaveBeenCalled();
|
||||
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: collector.drain(),
|
||||
cfg,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(afterConfigWritten).toHaveBeenCalledWith({
|
||||
previousCfg: {} as OpenClawConfig,
|
||||
cfg,
|
||||
accountId: "acct-1",
|
||||
runtime,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs onboarding post-write hook failures without aborting", async () => {
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: [
|
||||
{
|
||||
channel: "telegram",
|
||||
accountId: "acct-1",
|
||||
run: async () => {
|
||||
throw new Error("hook failed");
|
||||
},
|
||||
},
|
||||
],
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
'Channel telegram post-setup warning for "acct-1": hook failed',
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
ChannelSetupDmPolicy,
|
||||
ChannelSetupResult,
|
||||
ChannelSetupStatus,
|
||||
ChannelOnboardingPostWriteHook,
|
||||
SetupChannelsOptions,
|
||||
} from "./channel-setup/types.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
|
|
@ -46,6 +47,37 @@ type ChannelStatusSummary = {
|
|||
statusLines: string[];
|
||||
};
|
||||
|
||||
export function createChannelOnboardingPostWriteHookCollector() {
|
||||
const hooks = new Map<string, ChannelOnboardingPostWriteHook>();
|
||||
return {
|
||||
collect(hook: ChannelOnboardingPostWriteHook) {
|
||||
hooks.set(`${hook.channel}:${hook.accountId}`, hook);
|
||||
},
|
||||
drain(): ChannelOnboardingPostWriteHook[] {
|
||||
const next = [...hooks.values()];
|
||||
hooks.clear();
|
||||
return next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCollectedChannelOnboardingPostWriteHooks(params: {
|
||||
hooks: ChannelOnboardingPostWriteHook[];
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
for (const hook of params.hooks) {
|
||||
try {
|
||||
await hook.run({ cfg: params.cfg, runtime: params.runtime });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
params.runtime.error(
|
||||
`Channel ${hook.channel} post-setup warning for "${hook.accountId}": ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatAccountLabel(accountId: string): string {
|
||||
return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId;
|
||||
}
|
||||
|
|
@ -608,9 +640,24 @@ export async function setupChannels(
|
|||
};
|
||||
|
||||
const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => {
|
||||
const previousCfg = next;
|
||||
next = result.cfg;
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
if (result.accountId) {
|
||||
recordAccount(channel, result.accountId);
|
||||
if (adapter?.afterConfigWritten) {
|
||||
options?.onPostWriteHook?.({
|
||||
channel,
|
||||
accountId: result.accountId,
|
||||
run: async ({ cfg, runtime }) =>
|
||||
await adapter.afterConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg,
|
||||
accountId: result.accountId!,
|
||||
runtime,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
addSelection(channel);
|
||||
await refreshStatus(channel);
|
||||
|
|
|
|||
Loading…
Reference in New Issue