From 21b0a37a8691603b474ca7f6ef92ce7175d821f9 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Tue, 10 Mar 2026 09:14:40 -1000 Subject: [PATCH] feat: add webhook commands Add commands to manage webhooks in a Prismic repository: create, list, view, remove, test, enable, disable, status, add-header, remove-header, and set-triggers. Co-Authored-By: Claude Opus 4.6 --- src/clients/wroom.ts | 120 ++++++++++++++++++++++++++++++++ src/index.ts | 5 ++ src/webhook-add-header.ts | 100 +++++++++++++++++++++++++++ src/webhook-create.ts | 127 ++++++++++++++++++++++++++++++++++ src/webhook-disable.ts | 91 +++++++++++++++++++++++++ src/webhook-enable.ts | 91 +++++++++++++++++++++++++ src/webhook-list.ts | 69 +++++++++++++++++++ src/webhook-remove-header.ts | 99 +++++++++++++++++++++++++++ src/webhook-remove.ts | 83 +++++++++++++++++++++++ src/webhook-set-triggers.ts | 128 +++++++++++++++++++++++++++++++++++ src/webhook-status.ts | 69 +++++++++++++++++++ src/webhook-test.ts | 83 +++++++++++++++++++++++ src/webhook-view.ts | 103 ++++++++++++++++++++++++++++ src/webhook.ts | 95 ++++++++++++++++++++++++++ 14 files changed, 1263 insertions(+) create mode 100644 src/clients/wroom.ts create mode 100644 src/webhook-add-header.ts create mode 100644 src/webhook-create.ts create mode 100644 src/webhook-disable.ts create mode 100644 src/webhook-enable.ts create mode 100644 src/webhook-list.ts create mode 100644 src/webhook-remove-header.ts create mode 100644 src/webhook-remove.ts create mode 100644 src/webhook-set-triggers.ts create mode 100644 src/webhook-status.ts create mode 100644 src/webhook-test.ts create mode 100644 src/webhook-view.ts create mode 100644 src/webhook.ts diff --git a/src/clients/wroom.ts b/src/clients/wroom.ts new file mode 100644 index 0000000..51d7989 --- /dev/null +++ b/src/clients/wroom.ts @@ -0,0 +1,120 @@ +import * as v from "valibot"; + +import { env } from "../lib/env"; +import { request } from "../lib/request"; + +const WebhookSchema = v.object({ + config: v.object({ + _id: v.string(), + url: v.string(), + active: v.boolean(), + name: v.nullable(v.string()), + secret: v.nullable(v.string()), + headers: v.record(v.string(), v.string()), + documentsPublished: v.boolean(), + documentsUnpublished: v.boolean(), + releasesCreated: v.boolean(), + releasesUpdated: v.boolean(), + tagsCreated: v.boolean(), + tagsDeleted: v.boolean(), + }), +}); +type Webhook = v.InferOutput; + +export async function getWebhooks(config: { + repo: string; + token: string | undefined; + host: string | undefined; +}): Promise { + const { repo, token, host } = config; + const wroomUrl = getWroomUrl(repo, host); + const url = new URL("app/settings/webhooks", wroomUrl); + const response = await request(url, { + credentials: { "prismic-auth": token }, + schema: v.array(WebhookSchema), + }); + return response; +} + +export async function triggerWebhook( + id: string, + config: { repo: string; token: string | undefined; host: string | undefined }, +): Promise { + const { repo, token, host } = config; + const wroomUrl = getWroomUrl(repo, host); + const url = new URL(`app/settings/webhooks/${id}/trigger`, wroomUrl); + await request(url, { + method: "POST", + credentials: { "prismic-auth": token }, + }); +} + +export async function createWebhook( + webhookConfig: Omit, + config: { repo: string; token: string | undefined; host: string | undefined }, +): Promise { + const { repo, token, host } = config; + const wroomUrl = getWroomUrl(repo, host); + const url = new URL(`app/settings/webhooks/create`, wroomUrl); + const body = new FormData(); + body.set("url", webhookConfig.url); + body.set("name", webhookConfig.name ?? ""); + body.set("secret", webhookConfig.secret ?? ""); + body.set("headers", JSON.stringify({})); + body.set("active", "on"); + body.set("documentsPublished", webhookConfig.documentsUnpublished.toString()); + body.set("documentsUnpublished", webhookConfig.documentsUnpublished.toString()); + body.set("releasesCreated", webhookConfig.documentsUnpublished.toString()); + body.set("releasesUpdated", webhookConfig.documentsUnpublished.toString()); + body.set("tagsCreated", webhookConfig.documentsUnpublished.toString()); + body.set("documentsPublished", webhookConfig.documentsUnpublished.toString()); + await request(url, { + method: "POST", + body, + credentials: { "prismic-auth": token }, + }); +} + +export async function updateWebhook( + id: string, + webhookConfig: Omit, + config: { repo: string; token: string | undefined; host: string | undefined }, +): Promise { + const { repo, token, host } = config; + const wroomUrl = getWroomUrl(repo, host); + const url = new URL(`app/settings/webhooks/${id}`, wroomUrl); + const body = new FormData(); + body.set("url", webhookConfig.url); + body.set("name", webhookConfig.name ?? ""); + body.set("secret", webhookConfig.secret ?? ""); + body.set("headers", JSON.stringify(webhookConfig.headers ?? {})); + body.set("active", webhookConfig.active ? "on" : "off"); + body.set("documentsPublished", webhookConfig.documentsUnpublished.toString()); + body.set("documentsUnpublished", webhookConfig.documentsUnpublished.toString()); + body.set("releasesCreated", webhookConfig.documentsUnpublished.toString()); + body.set("releasesUpdated", webhookConfig.documentsUnpublished.toString()); + body.set("tagsCreated", webhookConfig.documentsUnpublished.toString()); + body.set("documentsPublished", webhookConfig.documentsUnpublished.toString()); + await request(url, { + method: "POST", + body, + credentials: { "prismic-auth": token }, + }); +} + +export async function deleteWebhook( + id: string, + config: { repo: string; token: string | undefined; host: string | undefined }, +): Promise { + const { repo, token, host } = config; + const wroomUrl = getWroomUrl(repo, host); + const url = new URL(`app/settings/webhooks/${id}/delete`, wroomUrl); + await request(url, { + method: "POST", + credentials: { "prismic-auth": token }, + }); +} + +function getWroomUrl(repo: string, host = env.PRISMIC_HOST): URL { + return new URL(`https://${repo}.${host}/`); +} diff --git a/src/index.ts b/src/index.ts index bb504d5..de09df2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { captureError, setupSentry } from "./lib/sentry"; import { login } from "./login"; import { logout } from "./logout"; import { sync } from "./sync"; +import { webhook } from "./webhook"; import { whoami } from "./whoami"; const HELP = ` @@ -22,6 +23,7 @@ USAGE COMMANDS init Initialize a Prismic project sync Sync types and slices from Prismic + webhook Manage webhooks login Log in to Prismic logout Log out of Prismic whoami Show the currently logged in user @@ -71,6 +73,9 @@ if (version) { case "logout": await logout(); break; + case "webhook": + await webhook(); + break; case "whoami": await whoami(); break; diff --git a/src/webhook-add-header.ts b/src/webhook-add-header.ts new file mode 100644 index 0000000..f6f5764 --- /dev/null +++ b/src/webhook-add-header.ts @@ -0,0 +1,100 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks, updateWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; + +const HELP = ` +Add a custom HTTP header to a webhook. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook add-header [flags] + +ARGUMENTS + Webhook URL + Header name + Header value + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookAddHeader(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl, headerKey, headerValue], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "add-header" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!headerKey) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!headerValue) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + const id = webhook.config._id; + + const updatedConfig = structuredClone(webhook.config); + updatedConfig.headers[headerKey] = headerValue; + + try { + await updateWebhook(id, updatedConfig, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to add header: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Header added: ${headerKey}`); +} diff --git a/src/webhook-create.ts b/src/webhook-create.ts new file mode 100644 index 0000000..7d7bcb9 --- /dev/null +++ b/src/webhook-create.ts @@ -0,0 +1,127 @@ +import { parseArgs } from "node:util"; + +import { createWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; +import { TRIGGER_DISPLAY } from "./webhook-view"; + +const HELP = ` +Create a new webhook in a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook create [flags] + +ARGUMENTS + Webhook URL to receive events + +FLAGS + -n, --name string Webhook name + -s, --secret string Secret for webhook signature + -t, --trigger string Trigger events (can be repeated) + -r, --repo string Repository domain + -h, --help Show help for command + +TRIGGERS + document.published When documents are published + document.unpublished When documents are unpublished + release.created When a release is created + release.updated When a release is edited or deleted + tag.created When a tag is created + tag.deleted When a tag is deleted + +If no triggers specified, all are enabled. + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +const VALID_TRIGGERS = Object.values(TRIGGER_DISPLAY); + +export async function webhookCreate(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig(), name, secret, trigger = [] }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "create" + options: { + name: { type: "string", short: "n" }, + secret: { type: "string", short: "s" }, + trigger: { type: "string", multiple: true, short: "t" }, + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + // Validate triggers + for (const t of trigger) { + if (!VALID_TRIGGERS.includes(t)) { + console.error(`Invalid trigger: ${t}`); + console.error(`Valid triggers: ${VALID_TRIGGERS.join(", ")}`); + process.exitCode = 1; + return; + } + } + + // Build trigger settings + const defaultValue = trigger.length > 0 ? false : true; + const triggers: Record = { + documentsPublished: defaultValue, + documentsUnpublished: defaultValue, + releasesCreated: defaultValue, + releasesUpdated: defaultValue, + tagsCreated: defaultValue, + tagsDeleted: defaultValue, + }; + for (const t of trigger) { + const [apiField] = Object.entries(TRIGGER_DISPLAY).find(([, display]) => t === display) ?? []; + if (!apiField) continue; + triggers[apiField as keyof typeof TRIGGER_DISPLAY] = true; + } + + const token = await getToken(); + const host = await getHost(); + + try { + await createWebhook( + { + url: webhookUrl, + name: name ?? null, + secret: secret ?? null, + ...triggers, + }, + { repo, token, host }, + ); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to create webhook: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Webhook created: ${webhookUrl}`); +} diff --git a/src/webhook-disable.ts b/src/webhook-disable.ts new file mode 100644 index 0000000..4afa1fc --- /dev/null +++ b/src/webhook-disable.ts @@ -0,0 +1,91 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks, updateWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; + +const HELP = ` +Disable a webhook in a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook disable [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookDisable(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "disable" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + if (!webhook.config.active) { + console.info(`Webhook already disabled: ${webhookUrl}`); + return; + } + + const id = webhook.config._id; + + const updatedConfig = structuredClone(webhook.config); + updatedConfig.active = false; + + try { + await updateWebhook(id, updatedConfig, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to disable webhook: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Webhook disabled: ${webhookUrl}`); +} diff --git a/src/webhook-enable.ts b/src/webhook-enable.ts new file mode 100644 index 0000000..b5f394b --- /dev/null +++ b/src/webhook-enable.ts @@ -0,0 +1,91 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks, updateWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; + +const HELP = ` +Enable a webhook in a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook enable [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookEnable(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "enable" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + if (webhook.config.active) { + console.info(`Webhook already enabled: ${webhookUrl}`); + return; + } + + const id = webhook.config._id; + + const updatedConfig = structuredClone(webhook.config); + updatedConfig.active = true; + + try { + await updateWebhook(id, updatedConfig, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to enable webhook: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Webhook enabled: ${webhookUrl}`); +} diff --git a/src/webhook-list.ts b/src/webhook-list.ts new file mode 100644 index 0000000..92e031f --- /dev/null +++ b/src/webhook-list.ts @@ -0,0 +1,69 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { stringify } from "./lib/json"; + +const HELP = ` +List all webhooks in a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook list [flags] + +FLAGS + --json Output as JSON + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookList(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig(), json }, + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "list" + options: { + json: { type: "boolean" }, + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + + if (json) { + console.info(stringify(webhooks.map((webhook) => webhook.config))); + return; + } + + if (webhooks.length === 0) { + console.info("No webhooks configured."); + return; + } + + for (const webhook of webhooks) { + const status = webhook.config.active ? "enabled" : "disabled"; + const name = webhook.config.name ? ` (${webhook.config.name})` : ""; + console.info(`${webhook.config.url}${name} [${status}]`); + } +} diff --git a/src/webhook-remove-header.ts b/src/webhook-remove-header.ts new file mode 100644 index 0000000..adee752 --- /dev/null +++ b/src/webhook-remove-header.ts @@ -0,0 +1,99 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks, updateWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; + +const HELP = ` +Remove a custom HTTP header from a webhook. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook remove-header [flags] + +ARGUMENTS + Webhook URL + Header name + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookRemoveHeader(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl, headerKey], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "remove-header" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!headerKey) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + if (!(headerKey in webhook.config.headers)) { + console.error(`Header not found: ${headerKey}`); + process.exitCode = 1; + return; + } + + const id = webhook.config._id; + + const updatedConfig = structuredClone(webhook.config); + delete updatedConfig.headers[headerKey]; + + try { + await updateWebhook(id, updatedConfig, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to remove header: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Header removed: ${headerKey}`); +} diff --git a/src/webhook-remove.ts b/src/webhook-remove.ts new file mode 100644 index 0000000..c45c78d --- /dev/null +++ b/src/webhook-remove.ts @@ -0,0 +1,83 @@ +import { parseArgs } from "node:util"; + +import { deleteWebhook, getWebhooks } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; + +const HELP = ` +Delete a webhook from a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook remove [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookRemove(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "remove" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + const id = webhook.config._id; + + try { + await deleteWebhook(id, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to remove webhook: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Webhook removed: ${webhookUrl}`); +} diff --git a/src/webhook-set-triggers.ts b/src/webhook-set-triggers.ts new file mode 100644 index 0000000..497cff4 --- /dev/null +++ b/src/webhook-set-triggers.ts @@ -0,0 +1,128 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks, updateWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; +import { TRIGGER_DISPLAY } from "./webhook-view"; + +const HELP = ` +Update which events trigger a webhook. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook set-triggers [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -t, --trigger string Trigger events (can be repeated, at least one required) + -r, --repo string Repository domain + -h, --help Show help for command + +TRIGGERS + document.published When documents are published + document.unpublished When documents are unpublished + release.created When a release is created + release.updated When a release is edited or deleted + tag.created When a tag is created + tag.deleted When a tag is deleted + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +const VALID_TRIGGERS = Object.values(TRIGGER_DISPLAY); + +export async function webhookSetTriggers(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig(), trigger }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "set-triggers" + options: { + trigger: { type: "string", multiple: true, short: "t" }, + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + if (!trigger || trigger.length === 0) { + console.error("Missing required option: --trigger"); + process.exitCode = 1; + return; + } + + // Validate triggers + for (const t of trigger) { + if (!VALID_TRIGGERS.includes(t)) { + console.error(`Invalid trigger: ${t}`); + console.error(`Valid triggers: ${VALID_TRIGGERS.join(", ")}`); + process.exitCode = 1; + return; + } + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + const id = webhook.config._id; + + // Build trigger settings: all false, then enable specified ones + const defaultValue = trigger.length > 0 ? false : true; + const triggers: Record = { + documentsPublished: defaultValue, + documentsUnpublished: defaultValue, + releasesCreated: defaultValue, + releasesUpdated: defaultValue, + tagsCreated: defaultValue, + tagsDeleted: defaultValue, + }; + for (const t of trigger) { + const [apiField] = Object.entries(TRIGGER_DISPLAY).find(([, display]) => t === display) ?? []; + if (!apiField) continue; + triggers[apiField as keyof typeof TRIGGER_DISPLAY] = true; + } + + try { + await updateWebhook(id, { ...webhook.config, ...triggers }, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to update webhook triggers: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Webhook triggers updated: ${trigger.join(", ")}`); +} diff --git a/src/webhook-status.ts b/src/webhook-status.ts new file mode 100644 index 0000000..9520ee0 --- /dev/null +++ b/src/webhook-status.ts @@ -0,0 +1,69 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; + +const HELP = ` +Show the enabled/disabled status of a webhook. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook status [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookStatus(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "status" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + const status = webhook.config.active ? "enabled" : "disabled"; + console.info(status); +} diff --git a/src/webhook-test.ts b/src/webhook-test.ts new file mode 100644 index 0000000..0e29a80 --- /dev/null +++ b/src/webhook-test.ts @@ -0,0 +1,83 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks, triggerWebhook } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; +import { UnknownRequestError } from "./lib/request"; + +const HELP = ` +Trigger a test webhook in a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook test [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export async function webhookTest(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "test" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + const webhook = webhooks.find((w) => w.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + const id = webhook.config._id; + + try { + await triggerWebhook(id, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + console.error(`Failed to test webhook: ${message}`); + process.exitCode = 1; + return; + } + throw error; + } + + console.info(`Test webhook triggered: ${webhookUrl}`); +} diff --git a/src/webhook-view.ts b/src/webhook-view.ts new file mode 100644 index 0000000..aa65838 --- /dev/null +++ b/src/webhook-view.ts @@ -0,0 +1,103 @@ +import { parseArgs } from "node:util"; + +import { getWebhooks } from "./clients/wroom"; +import { getHost, getToken } from "./lib/auth"; +import { safeGetRepositoryFromConfig } from "./lib/config"; + +const HELP = ` +View details of a webhook in a Prismic repository. + +By default, this command reads the repository from prismic.config.json at the +project root. + +USAGE + prismic webhook view [flags] + +ARGUMENTS + Webhook URL + +FLAGS + -r, --repo string Repository domain + -h, --help Show help for command + +LEARN MORE + Use \`prismic --help\` for more information about a command. +`.trim(); + +export const TRIGGER_DISPLAY = { + documentsPublished: "document.published", + documentsUnpublished: "document.unpublished", + releasesCreated: "release.created", + releasesUpdated: "release.updated", + tagsCreated: "tag.created", + tagsDeleted: "tag.deleted", +}; + +export async function webhookView(): Promise { + const { + values: { help, repo = await safeGetRepositoryFromConfig() }, + positionals: [webhookUrl], + } = parseArgs({ + args: process.argv.slice(4), // skip: node, script, "webhook", "view" + options: { + repo: { type: "string", short: "r" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (help) { + console.info(HELP); + return; + } + + if (!webhookUrl) { + console.error("Missing required argument: "); + process.exitCode = 1; + return; + } + + if (!repo) { + console.error("Missing prismic.config.json or --repo option"); + process.exitCode = 1; + return; + } + + const token = await getToken(); + const host = await getHost(); + const webhooks = await getWebhooks({ repo, token, host }); + + const webhook = webhooks.find((webhook) => webhook.config.url === webhookUrl); + if (!webhook) { + console.error(`Webhook not found: ${webhookUrl}`); + process.exitCode = 1; + return; + } + + const { config } = webhook; + + console.info(`URL: ${config.url}`); + console.info(`Name: ${config.name || "(none)"}`); + console.info(`Status: ${config.active ? "enabled" : "disabled"}`); + console.info(`Secret: ${config.secret ? "(set)" : "(none)"}`); + + // Show triggers + const enabledTriggers: string[] = []; + for (const [apiField, displayName] of Object.entries(TRIGGER_DISPLAY)) { + if (config[apiField as keyof typeof config]) { + enabledTriggers.push(displayName); + } + } + console.info(`Triggers: ${enabledTriggers.length > 0 ? enabledTriggers.join(", ") : "(none)"}`); + + // Show headers + const headerKeys = Object.keys(config.headers); + if (headerKeys.length > 0) { + console.info("Headers:"); + for (const [key, value] of Object.entries(config.headers)) { + console.info(` ${key}: ${value}`); + } + } else { + console.info("Headers: (none)"); + } +} diff --git a/src/webhook.ts b/src/webhook.ts new file mode 100644 index 0000000..63c4a67 --- /dev/null +++ b/src/webhook.ts @@ -0,0 +1,95 @@ +import { parseArgs } from "node:util"; + +import { webhookAddHeader } from "./webhook-add-header"; +import { webhookCreate } from "./webhook-create"; +import { webhookDisable } from "./webhook-disable"; +import { webhookEnable } from "./webhook-enable"; +import { webhookList } from "./webhook-list"; +import { webhookRemove } from "./webhook-remove"; +import { webhookRemoveHeader } from "./webhook-remove-header"; +import { webhookSetTriggers } from "./webhook-set-triggers"; +import { webhookStatus } from "./webhook-status"; +import { webhookTest } from "./webhook-test"; +import { webhookView } from "./webhook-view"; + +const HELP = ` +Manage webhooks in a Prismic repository. + +USAGE + prismic webhook [flags] + +COMMANDS + list List all webhooks + create Create a new webhook + view View webhook details + remove Delete a webhook + test Trigger a test webhook + enable Enable a webhook + disable Disable a webhook + status Show webhook enabled/disabled status + add-header Add a custom HTTP header + remove-header Remove a custom HTTP header + set-triggers Update webhook triggers + +FLAGS + -h, --help Show help for command + +LEARN MORE + Use \`prismic webhook --help\` for more information about a command. +`.trim(); + +export async function webhook(): Promise { + const { + positionals: [subcommand], + } = parseArgs({ + args: process.argv.slice(3), // skip: node, script, "webhook" + options: { + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + strict: false, + }); + + switch (subcommand) { + case "list": + await webhookList(); + break; + case "create": + await webhookCreate(); + break; + case "view": + await webhookView(); + break; + case "remove": + await webhookRemove(); + break; + case "test": + await webhookTest(); + break; + case "enable": + await webhookEnable(); + break; + case "disable": + await webhookDisable(); + break; + case "status": + await webhookStatus(); + break; + case "add-header": + await webhookAddHeader(); + break; + case "remove-header": + await webhookRemoveHeader(); + break; + case "set-triggers": + await webhookSetTriggers(); + break; + default: { + if (subcommand) { + console.error(`Unknown webhook subcommand: ${subcommand}\n`); + process.exitCode = 1; + } + console.info(HELP); + } + } +}