agent-orchestrator/packages/cli/src/lib/webhook-setup.ts

537 lines
16 KiB
TypeScript

import { readFileSync, writeFileSync } from "node:fs";
import chalk from "chalk";
import { parseDocument } from "yaml";
import { CONFIG_SCHEMA_URL, findConfigFile, isCanonicalGlobalConfigPath } from "@aoagents/ao-core";
import {
applyNotifierRoutingPreset,
getNotifierRoutingState,
promptNotifierRoutingPreset,
resolveRoutingPresetOption,
type ClackPrompts,
type NotifierRoutingPreset,
} from "./notifier-routing.js";
const DEFAULT_RETRIES = 2;
const DEFAULT_RETRY_DELAY_MS = 1000;
const SETUP_TIMEOUT_MS = 10_000;
export interface WebhookSetupOptions {
url?: string;
authToken?: string;
refresh?: boolean;
status?: boolean;
test?: boolean;
force?: boolean;
nonInteractive?: boolean;
routingPreset?: string;
}
interface WebhookConfig {
plugin: "webhook";
url: string;
headers?: Record<string, string>;
retries: number;
retryDelayMs: number;
}
interface ConfigContext {
configPath: string;
rawConfig: Record<string, unknown>;
}
interface ResolvedWebhookSetup {
url: string;
headers?: Record<string, string>;
retries: number;
retryDelayMs: number;
shouldSendTest: boolean;
routingPreset?: NotifierRoutingPreset;
}
export class WebhookSetupError extends Error {
constructor(
message: string,
public readonly exitCode = 1,
) {
super(message);
this.name = "WebhookSetupError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function validateWebhookUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new WebhookSetupError("Webhook URL is invalid.");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new WebhookSetupError("Webhook URL must start with http:// or https://.");
}
}
function readConfigContext(): ConfigContext {
const configPath = findConfigFile() ?? undefined;
if (!configPath) {
throw new WebhookSetupError(
"No agent-orchestrator.yaml found. Run 'ao start' first to create one.",
);
}
const rawYaml = readFileSync(configPath, "utf-8");
const doc = parseDocument(rawYaml);
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
return { configPath, rawConfig };
}
function getExistingWebhook(rawConfig: Record<string, unknown>): Record<string, unknown> {
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
const existing = notifiers["webhook"];
return isRecord(existing) ? existing : {};
}
function getBearerToken(headers: unknown): string | undefined {
if (!isRecord(headers)) return undefined;
const authorization = stringValue(headers["Authorization"] ?? headers["authorization"]);
const match = authorization?.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim();
}
function numberValue(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
function formatFetchError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function buildSetupPayload(): Record<string, unknown> {
return {
type: "notification",
event: {
id: `webhook-setup-${Date.now()}`,
type: "setup.webhook",
priority: "info",
sessionId: "setup",
projectId: "ao",
timestamp: new Date().toISOString(),
message: "AO webhook notifications are ready.",
data: {
source: "ao-setup-webhook",
},
},
};
}
async function sendSetupProbe(resolved: ResolvedWebhookSetup): Promise<void> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), SETUP_TIMEOUT_MS);
try {
const response = await fetch(resolved.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(resolved.headers ?? {}),
},
body: JSON.stringify(buildSetupPayload()),
signal: controller.signal,
});
if (response.ok) return;
const body = await response.text().catch(() => "");
throw new WebhookSetupError(
`Webhook setup test failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})${body ? `: ${body}` : ""}`,
);
} catch (error) {
if (error instanceof WebhookSetupError) throw error;
throw new WebhookSetupError(`Webhook setup test failed: ${formatFetchError(error)}`);
} finally {
clearTimeout(timer);
}
}
async function shouldReplaceConflictingWebhook(
existingPlugin: unknown,
force: boolean,
nonInteractive: boolean,
): Promise<boolean> {
if (existingPlugin === undefined || existingPlugin === "webhook" || force) return true;
if (nonInteractive) {
throw new WebhookSetupError(
`notifiers.webhook already uses plugin "${String(existingPlugin)}". Re-run with --force to replace it.`,
);
}
const clack = await import("@clack/prompts");
const replace = await clack.confirm({
message: `notifiers.webhook already uses plugin "${String(existingPlugin)}". Replace it?`,
initialValue: false,
});
if (clack.isCancel(replace) || !replace) {
console.log(chalk.dim("Keeping existing webhook notifier config."));
return false;
}
return true;
}
function cancelSetup(clack: ClackPrompts): never {
clack.cancel("Setup cancelled.");
throw new WebhookSetupError("Setup cancelled.", 0);
}
async function promptWebhookUrl(
clack: ClackPrompts,
initialValue: string | undefined,
): Promise<string> {
const urlInput = await clack.text({
message: "Webhook URL:",
placeholder: "https://example.com/ao-events",
initialValue,
validate: (value) => {
if (!value) return "URL is required";
try {
validateWebhookUrl(String(value));
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
},
});
if (clack.isCancel(urlInput)) {
cancelSetup(clack);
}
return String(urlInput);
}
async function promptChangeWebhookUrl(
clack: ClackPrompts,
existingUrl: string | undefined,
): Promise<string | "back"> {
const next = await clack.select({
message: "How do you want to configure the webhook URL?",
options: [
{
value: "enter-url",
label: "Add new webhook URL",
hint: "Paste a different HTTP endpoint",
},
{
value: "back",
label: "Back",
hint: "Return to the previous options",
},
{
value: "cancel",
label: "Cancel setup",
hint: "Do not change config",
},
],
});
if (clack.isCancel(next) || next === "cancel") {
cancelSetup(clack);
}
if (next === "back") return "back";
return promptWebhookUrl(clack, existingUrl);
}
async function resolveInteractiveUrl(
clack: ClackPrompts,
opts: WebhookSetupOptions,
existingUrl: string | undefined,
): Promise<string> {
const providedUrl = stringValue(opts.url);
if (providedUrl) return providedUrl;
while (true) {
if (!existingUrl) {
return promptWebhookUrl(clack, undefined);
}
const source = await clack.select({
message: "Webhook notifier is already configured. What do you want to do?",
options: [
{
value: "use-existing",
label: "Use existing webhook URL",
hint: "Keep sending to the currently configured endpoint",
},
{
value: "add-new",
label: "Add new webhook URL",
hint: "Paste a different HTTP endpoint",
},
{
value: "cancel",
label: "Cancel setup",
hint: "Do not change config",
},
],
});
if (clack.isCancel(source) || source === "cancel") {
cancelSetup(clack);
}
if (source === "use-existing") {
return existingUrl;
}
if (source === "add-new") {
const result = await promptChangeWebhookUrl(clack, existingUrl);
if (result === "back") continue;
return result;
}
}
}
async function resolveInteractiveSetup(
opts: WebhookSetupOptions,
existingWebhook: Record<string, unknown>,
rawConfig: Record<string, unknown>,
): Promise<ResolvedWebhookSetup> {
const clack = await import("@clack/prompts");
const existingUrl = stringValue(existingWebhook["url"]);
const existingToken = getBearerToken(existingWebhook["headers"]);
const optionRoutingPreset = resolveWebhookRoutingPreset(opts.routingPreset);
clack.intro(chalk.bgCyan(chalk.black(" ao setup webhook ")));
while (true) {
const resolvedUrl = await resolveInteractiveUrl(clack, opts, existingUrl);
let authToken = stringValue(opts.authToken);
if (!authToken && existingToken) {
const keepExisting = await clack.confirm({
message: "Keep the existing Authorization bearer token?",
initialValue: true,
});
if (clack.isCancel(keepExisting)) {
cancelSetup(clack);
}
if (keepExisting) authToken = existingToken;
}
if (!authToken) {
const tokenInput = await clack.password({
message: "Auth token (optional; leave blank for none):",
});
if (clack.isCancel(tokenInput)) {
cancelSetup(clack);
}
authToken = stringValue(tokenInput);
}
const routingSelection =
optionRoutingPreset ??
(await promptNotifierRoutingPreset(clack, rawConfig, "webhook", "webhook", () =>
cancelSetup(clack),
));
if (routingSelection === "back") continue;
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
return buildResolvedSetup(
resolvedUrl,
authToken,
retries,
retryDelayMs,
routingSelection === "preserve" ? undefined : routingSelection,
opts,
);
}
}
function resolveNonInteractiveSetup(
opts: WebhookSetupOptions,
existingWebhook: Record<string, unknown>,
): ResolvedWebhookSetup {
const url =
stringValue(opts.url) ?? (opts.refresh ? stringValue(existingWebhook["url"]) : undefined);
if (!url) {
throw new WebhookSetupError(
"Webhook URL is required. Pass --url, or run `ao setup webhook --refresh` with an existing webhook config.",
);
}
const authToken = stringValue(opts.authToken) ?? getBearerToken(existingWebhook["headers"]);
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
const routingPreset =
resolveWebhookRoutingPreset(opts.routingPreset) ?? (opts.refresh ? undefined : "all");
return buildResolvedSetup(url, authToken, retries, retryDelayMs, routingPreset, opts);
}
function buildResolvedSetup(
url: string,
authToken: string | undefined,
retries: number,
retryDelayMs: number,
routingPreset: NotifierRoutingPreset | undefined,
opts: WebhookSetupOptions,
): ResolvedWebhookSetup {
const normalizedUrl = url.trim();
validateWebhookUrl(normalizedUrl);
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined;
return {
url: normalizedUrl,
headers,
retries,
retryDelayMs,
shouldSendTest: opts.test !== false,
routingPreset,
};
}
function resolveWebhookRoutingPreset(value: string | undefined): NotifierRoutingPreset | undefined {
try {
return resolveRoutingPresetOption(value, "webhook") as NotifierRoutingPreset | undefined;
} catch (error) {
throw new WebhookSetupError(error instanceof Error ? error.message : String(error));
}
}
function toWebhookConfig(resolved: ResolvedWebhookSetup): WebhookConfig {
return {
plugin: "webhook",
url: resolved.url,
...(resolved.headers ? { headers: resolved.headers } : {}),
retries: resolved.retries,
retryDelayMs: resolved.retryDelayMs,
};
}
function writeWebhookConfig(configPath: string, resolved: ResolvedWebhookSetup): void {
const rawYaml = readFileSync(configPath, "utf-8");
const doc = parseDocument(rawYaml);
const rawConfig = (doc.toJS() as Record<string, unknown>) ?? {};
const notifiers = isRecord(rawConfig["notifiers"]) ? rawConfig["notifiers"] : {};
notifiers["webhook"] = toWebhookConfig(resolved);
rawConfig["notifiers"] = notifiers;
const defaults = isRecord(rawConfig["defaults"]) ? rawConfig["defaults"] : {};
rawConfig["defaults"] = defaults;
applyNotifierRoutingPreset(rawConfig, "webhook", resolved.routingPreset);
if (!isCanonicalGlobalConfigPath(configPath)) {
const currentSchema = doc.get("$schema");
if (!(typeof currentSchema === "string" && currentSchema.trim().length > 0)) {
doc.set("$schema", CONFIG_SCHEMA_URL);
}
}
doc.setIn(["notifiers"], rawConfig["notifiers"]);
doc.setIn(["defaults"], rawConfig["defaults"]);
if (rawConfig["notificationRouting"] !== undefined) {
doc.setIn(["notificationRouting"], rawConfig["notificationRouting"]);
}
writeFileSync(configPath, doc.toString({ indent: 2 }));
}
async function printStatus(): Promise<void> {
const context = readConfigContext();
const existingWebhook = getExistingWebhook(context.rawConfig);
const plugin = stringValue(existingWebhook["plugin"]);
const url = stringValue(existingWebhook["url"]);
const hasAuth = Boolean(getBearerToken(existingWebhook["headers"]));
const retries = numberValue(existingWebhook["retries"], DEFAULT_RETRIES);
const retryDelayMs = numberValue(existingWebhook["retryDelayMs"], DEFAULT_RETRY_DELAY_MS);
console.log(chalk.bold("Webhook notifier status"));
console.log(` Config: ${context.configPath}`);
console.log(` Plugin: ${plugin ?? chalk.dim("not configured")}`);
console.log(` URL: ${url ?? chalk.dim("not configured")}`);
console.log(` Auth: ${hasAuth ? "Authorization bearer token configured" : "none"}`);
console.log(` Retries: ${retries}`);
console.log(` Retry delay: ${retryDelayMs}ms`);
console.log(` Routing: ${getNotifierRoutingState(context.rawConfig, "webhook").label}`);
if (plugin !== "webhook" || !url) return;
try {
await sendSetupProbe(
buildResolvedSetup(
url,
getBearerToken(existingWebhook["headers"]),
retries,
retryDelayMs,
undefined,
{ test: true },
),
);
console.log(chalk.green(" Probe: PASS"));
} catch (error) {
console.log(
chalk.red(` Probe: FAIL ${error instanceof Error ? error.message : String(error)}`),
);
}
}
export async function runWebhookSetupAction(opts: WebhookSetupOptions): Promise<void> {
const nonInteractive = opts.nonInteractive || !process.stdin.isTTY;
const force = Boolean(opts.force);
if (opts.status) {
await printStatus();
return;
}
const context = readConfigContext();
const existingWebhook = getExistingWebhook(context.rawConfig);
const shouldWire = await shouldReplaceConflictingWebhook(
existingWebhook["plugin"],
force,
nonInteractive,
);
if (!shouldWire) return;
const resolved = nonInteractive
? resolveNonInteractiveSetup(opts, existingWebhook)
: await resolveInteractiveSetup(opts, existingWebhook, context.rawConfig);
if (resolved.shouldSendTest) {
await sendSetupProbe(resolved);
console.log(chalk.green("✓ Webhook setup test passed"));
} else {
console.log(chalk.dim("Skipped webhook setup test."));
}
writeWebhookConfig(context.configPath, resolved);
console.log(chalk.green(`✓ Config written to ${context.configPath}`));
if (!nonInteractive) {
const clack = await import("@clack/prompts");
clack.outro(
`${chalk.green("Webhook setup complete!")} AO will send notifications to ${resolved.url}.\n` +
chalk.dim(" Test it with: ao notify test --to webhook --template basic"),
);
} else {
console.log(chalk.green("\n✓ Webhook setup complete."));
console.log(chalk.dim("Test it with: ao notify test --to webhook --template basic"));
}
}