great-cto 2.3.4 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/serve.js ADDED
@@ -0,0 +1,289 @@
1
+ // great-cto serve — webhook receiver with HMAC verification, retry, DLQ.
2
+ // v2.5.0 production-grade upgrade.
3
+ //
4
+ // Incoming endpoints:
5
+ // POST /webhook/github GitHub events with X-Hub-Signature-256 verification
6
+ // POST /webhook/sentry Sentry alerts (HMAC via X-Sentry-Signature-256)
7
+ // POST /webhook/generic Generic JSON, optional shared-secret verification
8
+ // GET /events Recent event log (last 50)
9
+ // GET /healthz Liveness probe
10
+ // GET /dlq Recent dead-lettered outbound deliveries
11
+ //
12
+ // HMAC verification is REQUIRED unless GREATCTO_WEBHOOK_INSECURE=1 is set
13
+ // (intended only for local dev). Configure secrets via:
14
+ // great-cto webhook add-incoming github --secret <hmac-secret>
15
+ //
16
+ // Outbound dispatch fires automatically on certain incoming events:
17
+ // github.pull_request.opened → "pr.opened" event
18
+ // github.issues.opened → "issue.opened" event
19
+ // sentry.event_alert → "incident.p0" event (severity-mapped)
20
+ // Each registered outgoing hook listens to a subset via its triggers list.
21
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
22
+ import { createHmac, timingSafeEqual } from "node:crypto";
23
+ import { createServer } from "node:http";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import { dispatch, getDlqPath } from "./webhook-dispatch.js";
27
+ import { getIncoming } from "./webhook-config.js";
28
+ const EVENTS_LOG = join(homedir(), ".great_cto", "webhook-events.log");
29
+ function logEvent(ev, noLog) {
30
+ if (noLog)
31
+ return;
32
+ try {
33
+ const dir = join(homedir(), ".great_cto");
34
+ if (!existsSync(dir))
35
+ mkdirSync(dir, { recursive: true });
36
+ appendFileSync(EVENTS_LOG, JSON.stringify(ev) + "\n");
37
+ }
38
+ catch (e) {
39
+ process.stderr.write(`serve: failed to log event: ${e.message}\n`);
40
+ }
41
+ }
42
+ function readBody(req) {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks = [];
45
+ req.on("data", chunk => chunks.push(Buffer.from(chunk)));
46
+ req.on("end", () => resolve(Buffer.concat(chunks)));
47
+ req.on("error", reject);
48
+ });
49
+ }
50
+ function json(res, status, body) {
51
+ res.writeHead(status, { "Content-Type": "application/json" });
52
+ res.end(JSON.stringify(body));
53
+ }
54
+ // ── HMAC verification ──────────────────────────────────────────────────────
55
+ /**
56
+ * Constant-time HMAC-SHA256 verification. Returns true if signatures match.
57
+ * GitHub format: "sha256=<hex>"
58
+ * Sentry format: "<hex>" (just the digest)
59
+ * Generic format: either accepted
60
+ */
61
+ function verifyHmac(secret, body, headerValue) {
62
+ if (!headerValue)
63
+ return false;
64
+ // Strip "sha256=" prefix if present
65
+ const expected = headerValue.startsWith("sha256=") ? headerValue.slice(7) : headerValue;
66
+ const computed = createHmac("sha256", secret).update(body).digest("hex");
67
+ if (expected.length !== computed.length)
68
+ return false;
69
+ try {
70
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(computed, "hex"));
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ // ── Endpoint handlers ──────────────────────────────────────────────────────
77
+ async function handleGitHub(req, res, args) {
78
+ const body = await readBody(req);
79
+ const eventType = req.headers["x-github-event"] ?? "unknown";
80
+ const deliveryId = req.headers["x-github-delivery"] ?? "no-id";
81
+ const signature = req.headers["x-hub-signature-256"];
82
+ // HMAC verification
83
+ if (!args.insecure) {
84
+ const cfg = getIncoming("github");
85
+ if (!cfg?.secret) {
86
+ json(res, 401, {
87
+ error: "github webhook not configured",
88
+ hint: "run: great-cto webhook add-incoming github --secret <hmac-secret>",
89
+ });
90
+ return;
91
+ }
92
+ if (!verifyHmac(cfg.secret, body, signature)) {
93
+ json(res, 401, { error: "invalid signature" });
94
+ return;
95
+ }
96
+ }
97
+ let payload;
98
+ try {
99
+ payload = JSON.parse(body.toString("utf8"));
100
+ }
101
+ catch {
102
+ json(res, 400, { error: "invalid JSON" });
103
+ return;
104
+ }
105
+ const action = payload.action ?? "?";
106
+ const summary = eventType === "pull_request"
107
+ ? `${action} PR #${payload.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
108
+ : eventType === "issues"
109
+ ? `${action} issue #${payload.issue?.number ?? "?"} in ${payload.repository?.full_name ?? "?"}`
110
+ : `${eventType} delivery=${deliveryId}`;
111
+ // Outbound dispatch — map GitHub events to internal trigger names
112
+ let outboundFired = 0;
113
+ if (eventType === "pull_request" && action === "opened") {
114
+ outboundFired = dispatch({
115
+ name: "pr.opened",
116
+ level: "info",
117
+ title: `PR opened: #${payload.number} in ${payload.repository?.full_name}`,
118
+ body: payload.pull_request?.title ?? "",
119
+ meta: { url: payload.pull_request?.html_url, author: payload.sender?.login },
120
+ }).fired;
121
+ }
122
+ else if (eventType === "issues" && action === "opened") {
123
+ outboundFired = dispatch({
124
+ name: "issue.opened",
125
+ level: "info",
126
+ title: `Issue opened: #${payload.issue.number} in ${payload.repository?.full_name}`,
127
+ body: payload.issue?.title ?? "",
128
+ meta: { url: payload.issue?.html_url, author: payload.sender?.login },
129
+ }).fired;
130
+ }
131
+ logEvent({
132
+ ts: new Date().toISOString(),
133
+ source: "github",
134
+ event_type: eventType,
135
+ summary,
136
+ action_taken: outboundFired > 0 ? `dispatched to ${outboundFired} outbound hook(s)` : "logged",
137
+ meta: { delivery_id: deliveryId, pr_number: payload.number, action: payload.action },
138
+ }, args.noLog);
139
+ json(res, 200, { ok: true, event_type: eventType, dispatched_to: outboundFired });
140
+ }
141
+ async function handleSentry(req, res, args) {
142
+ const body = await readBody(req);
143
+ const signature = req.headers["x-sentry-signature-256"];
144
+ if (!args.insecure) {
145
+ const cfg = getIncoming("sentry");
146
+ if (!cfg?.secret) {
147
+ json(res, 401, { error: "sentry webhook not configured" });
148
+ return;
149
+ }
150
+ if (!verifyHmac(cfg.secret, body, signature)) {
151
+ json(res, 401, { error: "invalid signature" });
152
+ return;
153
+ }
154
+ }
155
+ let payload;
156
+ try {
157
+ payload = JSON.parse(body.toString("utf8"));
158
+ }
159
+ catch {
160
+ json(res, 400, { error: "invalid JSON" });
161
+ return;
162
+ }
163
+ // Sentry events typically include event.level: 'fatal' | 'error' | 'warning'
164
+ const level = payload?.event?.level ?? payload?.level ?? "warning";
165
+ const title = payload?.event?.title ?? payload?.title ?? "Sentry alert";
166
+ const isP0 = level === "fatal" || level === "critical";
167
+ const fired = dispatch({
168
+ name: isP0 ? "incident.p0" : "incident.alert",
169
+ level: isP0 ? "critical" : "error",
170
+ title,
171
+ body: payload?.event?.metadata?.value ?? "",
172
+ meta: { url: payload?.url, project: payload?.project_slug },
173
+ }).fired;
174
+ logEvent({
175
+ ts: new Date().toISOString(),
176
+ source: "sentry",
177
+ event_type: isP0 ? "p0" : "alert",
178
+ summary: title,
179
+ action_taken: `dispatched to ${fired} outbound hook(s)`,
180
+ meta: { level, url: payload?.url },
181
+ }, args.noLog);
182
+ json(res, 200, { ok: true, dispatched_to: fired });
183
+ }
184
+ async function handleGeneric(req, res, args) {
185
+ const body = await readBody(req);
186
+ const signature = req.headers["x-greatcto-signature-256"];
187
+ if (!args.insecure) {
188
+ const cfg = getIncoming("generic");
189
+ if (cfg?.secret && !verifyHmac(cfg.secret, body, signature)) {
190
+ json(res, 401, { error: "invalid signature" });
191
+ return;
192
+ }
193
+ }
194
+ let payload = body.toString("utf8");
195
+ try {
196
+ payload = JSON.parse(payload);
197
+ }
198
+ catch {
199
+ /* keep as raw string */
200
+ }
201
+ const ev = {
202
+ ts: new Date().toISOString(),
203
+ source: "generic",
204
+ event_type: "incoming",
205
+ summary: `payload ${body.length} bytes`,
206
+ action_taken: "logged",
207
+ meta: { payload_preview: String(body.toString("utf8")).slice(0, 200) },
208
+ };
209
+ logEvent(ev, args.noLog);
210
+ json(res, 200, { ok: true, recorded: true });
211
+ }
212
+ function handleEvents(_req, res) {
213
+ if (!existsSync(EVENTS_LOG)) {
214
+ json(res, 200, { events: [] });
215
+ return;
216
+ }
217
+ const lines = readFileSync(EVENTS_LOG, "utf8")
218
+ .split("\n")
219
+ .filter(Boolean)
220
+ .slice(-50);
221
+ const events = lines
222
+ .map(l => { try {
223
+ return JSON.parse(l);
224
+ }
225
+ catch {
226
+ return null;
227
+ } })
228
+ .filter(Boolean);
229
+ json(res, 200, { events });
230
+ }
231
+ function handleDlq(_req, res) {
232
+ const dlq = getDlqPath();
233
+ if (!existsSync(dlq)) {
234
+ json(res, 200, { dlq: [] });
235
+ return;
236
+ }
237
+ const lines = readFileSync(dlq, "utf8").split("\n").filter(Boolean).slice(-50);
238
+ const events = lines
239
+ .map(l => { try {
240
+ return JSON.parse(l);
241
+ }
242
+ catch {
243
+ return null;
244
+ } })
245
+ .filter(Boolean);
246
+ json(res, 200, { dlq: events });
247
+ }
248
+ // ── Main entry ─────────────────────────────────────────────────────────────
249
+ export async function runServe(args) {
250
+ const insecure = args.insecure ?? process.env.GREATCTO_WEBHOOK_INSECURE === "1";
251
+ const finalArgs = { ...args, insecure };
252
+ const server = createServer(async (req, res) => {
253
+ const url = new URL(req.url ?? "/", `http://localhost:${args.port}`);
254
+ const path = url.pathname;
255
+ if (req.method === "GET" && path === "/healthz") {
256
+ return json(res, 200, { ok: true, service: "great-cto serve", insecure });
257
+ }
258
+ if (req.method === "GET" && path === "/events") {
259
+ return handleEvents(req, res);
260
+ }
261
+ if (req.method === "GET" && path === "/dlq") {
262
+ return handleDlq(req, res);
263
+ }
264
+ if (req.method === "POST" && path === "/webhook/github") {
265
+ return handleGitHub(req, res, finalArgs);
266
+ }
267
+ if (req.method === "POST" && path === "/webhook/sentry") {
268
+ return handleSentry(req, res, finalArgs);
269
+ }
270
+ if (req.method === "POST" && path === "/webhook/generic") {
271
+ return handleGeneric(req, res, finalArgs);
272
+ }
273
+ json(res, 404, { error: "not found", path });
274
+ });
275
+ return new Promise(resolve => {
276
+ server.listen(args.port, "127.0.0.1", () => {
277
+ console.error(`great-cto serve → http://localhost:${args.port}${insecure ? " [INSECURE: HMAC OFF]" : ""}`);
278
+ console.error(` POST /webhook/github GitHub (HMAC SHA-256)`);
279
+ console.error(` POST /webhook/sentry Sentry (HMAC SHA-256)`);
280
+ console.error(` POST /webhook/generic Generic (optional HMAC)`);
281
+ console.error(` GET /events Recent event log`);
282
+ console.error(` GET /dlq Dead-letter queue`);
283
+ console.error(` GET /healthz Liveness probe`);
284
+ console.error(` log: ${EVENTS_LOG}`);
285
+ });
286
+ process.on("SIGINT", () => { server.close(); resolve(0); });
287
+ process.on("SIGTERM", () => { server.close(); resolve(0); });
288
+ });
289
+ }
@@ -0,0 +1,150 @@
1
+ // great-cto webhook — manage incoming/outgoing webhook configuration.
2
+ //
3
+ // Usage:
4
+ // great-cto webhook list
5
+ // great-cto webhook add-incoming <name> --secret <hmac> [--events e1,e2]
6
+ // great-cto webhook add-outgoing <name> --url <url> --format slack|discord|pagerduty|generic --triggers t1,t2
7
+ // great-cto webhook remove <name>
8
+ // great-cto webhook test <name> (sends a test event through dispatcher)
9
+ import { loadConfig, addIncoming, addOutgoing, removeHook, getConfigPath, } from "./webhook-config.js";
10
+ import { dispatch } from "./webhook-dispatch.js";
11
+ export async function runWebhookCli(args) {
12
+ switch (args.action) {
13
+ case "list": {
14
+ const cfg = loadConfig();
15
+ console.log(`config: ${getConfigPath()}\n`);
16
+ console.log(`Incoming hooks (${cfg.incoming.length}):`);
17
+ if (cfg.incoming.length === 0)
18
+ console.log(" (none)");
19
+ for (const h of cfg.incoming) {
20
+ const sec = h.secret ? `[secret: ${h.secret.slice(0, 4)}...${h.secret.slice(-2)}]` : "[NO SECRET — INSECURE]";
21
+ console.log(` - ${h.name} ${sec} events=${h.events?.join(",") || "all"}`);
22
+ }
23
+ console.log(`\nOutgoing hooks (${cfg.outgoing.length}):`);
24
+ if (cfg.outgoing.length === 0)
25
+ console.log(" (none)");
26
+ for (const h of cfg.outgoing) {
27
+ const url = h.url.length > 60 ? h.url.slice(0, 57) + "..." : h.url;
28
+ console.log(` - ${h.name} [${h.format}] triggers=${h.triggers.join(",")}\n ${url}`);
29
+ }
30
+ return 0;
31
+ }
32
+ case "add-incoming": {
33
+ if (!args.name) {
34
+ console.error("FAIL: --name required");
35
+ return 2;
36
+ }
37
+ if (!args.secret) {
38
+ console.error("WARN: no --secret provided. Webhooks will only work in --insecure mode.");
39
+ }
40
+ addIncoming({
41
+ name: args.name,
42
+ secret: args.secret,
43
+ events: args.events,
44
+ });
45
+ console.log(`✓ added incoming hook "${args.name}"`);
46
+ return 0;
47
+ }
48
+ case "add-outgoing": {
49
+ if (!args.name) {
50
+ console.error("FAIL: --name required");
51
+ return 2;
52
+ }
53
+ if (!args.url) {
54
+ console.error("FAIL: --url required");
55
+ return 2;
56
+ }
57
+ if (!args.format) {
58
+ console.error("FAIL: --format required");
59
+ return 2;
60
+ }
61
+ if (!args.triggers || args.triggers.length === 0) {
62
+ console.error("FAIL: --triggers required (comma-separated event names)");
63
+ return 2;
64
+ }
65
+ const headers = {};
66
+ if (args.routingKey)
67
+ headers.routing_key = args.routingKey;
68
+ addOutgoing({
69
+ name: args.name,
70
+ url: args.url,
71
+ format: args.format,
72
+ triggers: args.triggers,
73
+ headers: Object.keys(headers).length ? headers : undefined,
74
+ });
75
+ console.log(`✓ added outgoing hook "${args.name}" (${args.format}) → ${args.triggers.join(", ")}`);
76
+ return 0;
77
+ }
78
+ case "remove": {
79
+ if (!args.name) {
80
+ console.error("FAIL: --name required");
81
+ return 2;
82
+ }
83
+ const removed = removeHook(args.name);
84
+ if (removed) {
85
+ console.log(`✓ removed hook "${args.name}"`);
86
+ return 0;
87
+ }
88
+ console.error(`hook not found: ${args.name}`);
89
+ return 1;
90
+ }
91
+ case "test": {
92
+ if (!args.name) {
93
+ console.error("FAIL: --name required");
94
+ return 2;
95
+ }
96
+ const cfg = loadConfig();
97
+ const hook = cfg.outgoing.find(h => h.name === args.name);
98
+ if (!hook) {
99
+ console.error(`outgoing hook not found: ${args.name}`);
100
+ return 1;
101
+ }
102
+ const result = dispatch({
103
+ name: hook.triggers[0] ?? "test.event",
104
+ level: "info",
105
+ title: "great-cto webhook test",
106
+ body: "If you see this, your webhook is correctly configured.",
107
+ meta: { test: true, timestamp: new Date().toISOString() },
108
+ });
109
+ console.log(`✓ test event dispatched to ${result.fired} hook(s)`);
110
+ console.log(` (delivery is async — check destination shortly; check ~/.great_cto/webhook-dlq.log if it doesn't arrive)`);
111
+ // Give the in-flight request a moment before exit
112
+ await new Promise(r => setTimeout(r, 500));
113
+ return 0;
114
+ }
115
+ }
116
+ console.error(`unknown action: ${args.action}`);
117
+ return 2;
118
+ }
119
+ export function parseWebhookArgs(rawArgv) {
120
+ const idx = rawArgv.indexOf("webhook");
121
+ if (idx === -1)
122
+ return null;
123
+ const action = rawArgv[idx + 1];
124
+ if (!["list", "add-incoming", "add-outgoing", "remove", "test"].includes(action)) {
125
+ return null;
126
+ }
127
+ const flag = (n) => {
128
+ const i = rawArgv.indexOf(`--${n}`);
129
+ return i >= 0 && i < rawArgv.length - 1 ? rawArgv[i + 1] : undefined;
130
+ };
131
+ // First positional after action = name
132
+ let name;
133
+ for (let i = idx + 2; i < rawArgv.length; i++) {
134
+ const a = rawArgv[i];
135
+ if (!a.startsWith("--")) {
136
+ name = a;
137
+ break;
138
+ }
139
+ }
140
+ return {
141
+ action,
142
+ name,
143
+ secret: flag("secret"),
144
+ url: flag("url"),
145
+ format: flag("format"),
146
+ triggers: flag("triggers")?.split(",").map(s => s.trim()).filter(Boolean),
147
+ events: flag("events")?.split(",").map(s => s.trim()).filter(Boolean),
148
+ routingKey: flag("routing-key"),
149
+ };
150
+ }
@@ -0,0 +1,65 @@
1
+ // Webhook configuration store — persisted to ~/.great_cto/webhooks.json.
2
+ // Used by `serve` (incoming) and the dispatcher (outgoing).
3
+ //
4
+ // Schema:
5
+ // {
6
+ // "incoming": [
7
+ // { "name": "github", "secret": "<hmac-secret>", "events": ["pull_request"] }
8
+ // ],
9
+ // "outgoing": [
10
+ // { "name": "ops-slack", "url": "https://hooks.slack.com/services/...",
11
+ // "format": "slack", "triggers": ["gate.approved", "incident.p0"] }
12
+ // ]
13
+ // }
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ const CONFIG_PATH = join(homedir(), ".great_cto", "webhooks.json");
18
+ const DEFAULT_CONFIG = { incoming: [], outgoing: [] };
19
+ export function getConfigPath() {
20
+ return CONFIG_PATH;
21
+ }
22
+ export function loadConfig() {
23
+ if (!existsSync(CONFIG_PATH))
24
+ return { ...DEFAULT_CONFIG };
25
+ try {
26
+ const raw = readFileSync(CONFIG_PATH, "utf8");
27
+ const parsed = JSON.parse(raw);
28
+ return {
29
+ incoming: parsed.incoming ?? [],
30
+ outgoing: parsed.outgoing ?? [],
31
+ };
32
+ }
33
+ catch {
34
+ return { ...DEFAULT_CONFIG };
35
+ }
36
+ }
37
+ export function saveConfig(cfg) {
38
+ const dir = dirname(CONFIG_PATH);
39
+ if (!existsSync(dir))
40
+ mkdirSync(dir, { recursive: true });
41
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
42
+ }
43
+ export function addIncoming(hook) {
44
+ const cfg = loadConfig();
45
+ cfg.incoming = cfg.incoming.filter(h => h.name !== hook.name);
46
+ cfg.incoming.push({ enabled: true, ...hook });
47
+ saveConfig(cfg);
48
+ }
49
+ export function addOutgoing(hook) {
50
+ const cfg = loadConfig();
51
+ cfg.outgoing = cfg.outgoing.filter(h => h.name !== hook.name);
52
+ cfg.outgoing.push({ enabled: true, ...hook });
53
+ saveConfig(cfg);
54
+ }
55
+ export function removeHook(name) {
56
+ const cfg = loadConfig();
57
+ const before = cfg.incoming.length + cfg.outgoing.length;
58
+ cfg.incoming = cfg.incoming.filter(h => h.name !== name);
59
+ cfg.outgoing = cfg.outgoing.filter(h => h.name !== name);
60
+ saveConfig(cfg);
61
+ return cfg.incoming.length + cfg.outgoing.length < before;
62
+ }
63
+ export function getIncoming(name) {
64
+ return loadConfig().incoming.find(h => h.name === name) ?? null;
65
+ }
@@ -0,0 +1,132 @@
1
+ // Webhook dispatcher — outbound notifications with retry + DLQ.
2
+ //
3
+ // Reliability model:
4
+ // - In-memory retry queue with exponential backoff (1s, 4s, 16s, 64s)
5
+ // - Max 4 attempts per delivery
6
+ // - On final failure: append to dead-letter log (~/.great_cto/webhook-dlq.log)
7
+ // - Dispatcher is fire-and-forget for the caller — we never block the
8
+ // incoming-webhook handler on outbound success
9
+ //
10
+ // Format adapters:
11
+ // - slack: posts as Slack incoming-webhook JSON ({text, blocks?})
12
+ // - discord: Discord webhook JSON ({content, embeds?})
13
+ // - pagerduty: Events API v2 ({routing_key, event_action, payload})
14
+ // - generic: arbitrary JSON POST
15
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { loadConfig } from "./webhook-config.js";
19
+ const DLQ_PATH = join(homedir(), ".great_cto", "webhook-dlq.log");
20
+ const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000]; // 4 attempts total
21
+ // ── Format adapters ────────────────────────────────────────────────────────
22
+ function formatSlack(ev) {
23
+ const emoji = ev.level === "critical" ? ":rotating_light:"
24
+ : ev.level === "error" ? ":x:"
25
+ : ev.level === "warning" ? ":warning:"
26
+ : ":information_source:";
27
+ return {
28
+ text: `${emoji} *${ev.title}*`,
29
+ attachments: ev.body ? [{ text: ev.body, color: ev.level === "critical" ? "danger" : "good" }] : undefined,
30
+ };
31
+ }
32
+ function formatDiscord(ev) {
33
+ const color = ev.level === "critical" ? 0xff0000
34
+ : ev.level === "error" ? 0xff6600
35
+ : ev.level === "warning" ? 0xffcc00
36
+ : 0x00aa66;
37
+ return {
38
+ content: ev.title,
39
+ embeds: ev.body ? [{ description: ev.body, color }] : undefined,
40
+ };
41
+ }
42
+ function formatPagerDuty(ev, routingKey) {
43
+ // PagerDuty Events API v2
44
+ const severity = ev.level === "critical" ? "critical"
45
+ : ev.level === "error" ? "error"
46
+ : ev.level === "warning" ? "warning"
47
+ : "info";
48
+ return {
49
+ routing_key: routingKey,
50
+ event_action: "trigger",
51
+ payload: {
52
+ summary: ev.title,
53
+ source: "great-cto",
54
+ severity,
55
+ custom_details: ev.body ? { details: ev.body, ...(ev.meta ?? {}) } : ev.meta,
56
+ },
57
+ };
58
+ }
59
+ function buildPayload(hook, ev) {
60
+ switch (hook.format) {
61
+ case "slack": return formatSlack(ev);
62
+ case "discord": return formatDiscord(ev);
63
+ case "pagerduty": {
64
+ // PagerDuty uses routing_key from headers config: headers.routing_key
65
+ const key = hook.headers?.routing_key ?? "";
66
+ return formatPagerDuty(ev, key);
67
+ }
68
+ case "generic":
69
+ default: return { event: ev.name, ...ev };
70
+ }
71
+ }
72
+ // ── Retry / DLQ ────────────────────────────────────────────────────────────
73
+ async function deliver(hook, ev, attempt = 0) {
74
+ try {
75
+ const body = JSON.stringify(buildPayload(hook, ev));
76
+ const headers = {
77
+ "Content-Type": "application/json",
78
+ ...hook.headers,
79
+ };
80
+ // Don't leak routing_key as HTTP header — PagerDuty wants it in body
81
+ delete headers.routing_key;
82
+ const res = await fetch(hook.url, { method: "POST", headers, body });
83
+ if (!res.ok) {
84
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
85
+ }
86
+ }
87
+ catch (err) {
88
+ const next = attempt + 1;
89
+ if (next < RETRY_DELAYS_MS.length) {
90
+ setTimeout(() => { void deliver(hook, ev, next); }, RETRY_DELAYS_MS[next]);
91
+ return;
92
+ }
93
+ // Final failure — write to DLQ
94
+ writeToDlq(hook, ev, err);
95
+ }
96
+ }
97
+ function writeToDlq(hook, ev, err) {
98
+ try {
99
+ const dir = dirname(DLQ_PATH);
100
+ if (!existsSync(dir))
101
+ mkdirSync(dir, { recursive: true });
102
+ const entry = {
103
+ ts: new Date().toISOString(),
104
+ hook: hook.name,
105
+ url: hook.url,
106
+ event: ev,
107
+ error: err.message,
108
+ };
109
+ appendFileSync(DLQ_PATH, JSON.stringify(entry) + "\n");
110
+ process.stderr.write(`webhook-dispatch: ${hook.name} dead-lettered: ${err.message}\n`);
111
+ }
112
+ catch {
113
+ /* even DLQ failed — no recovery */
114
+ }
115
+ }
116
+ // ── Public API ─────────────────────────────────────────────────────────────
117
+ /**
118
+ * Fire-and-forget dispatch to all outbound webhooks whose triggers include
119
+ * the event's name. The caller does not await delivery — we run all
120
+ * dispatchers in parallel with their own retry queues.
121
+ */
122
+ export function dispatch(ev) {
123
+ const cfg = loadConfig();
124
+ const targets = cfg.outgoing.filter(h => (h.enabled !== false) && h.triggers.includes(ev.name));
125
+ for (const hook of targets) {
126
+ void deliver(hook, ev, 0);
127
+ }
128
+ return { fired: targets.length };
129
+ }
130
+ export function getDlqPath() {
131
+ return DLQ_PATH;
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.3.4",
3
+ "version": "2.5.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",