@hasna/events 0.1.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.
@@ -0,0 +1,811 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/cli/index.ts
5
+ import { readFileSync } from "fs";
6
+ import { dirname, join as join2 } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // src/index.ts
10
+ import { randomUUID as randomUUID2 } from "crypto";
11
+
12
+ // src/filter.ts
13
+ function getPathValue(input, path) {
14
+ return path.split(".").reduce((value, part) => {
15
+ if (value && typeof value === "object" && part in value) {
16
+ return value[part];
17
+ }
18
+ return;
19
+ }, input);
20
+ }
21
+ function wildcardToRegExp(pattern) {
22
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
23
+ return new RegExp(`^${escaped}$`);
24
+ }
25
+ function matchString(value, matcher) {
26
+ if (matcher === undefined)
27
+ return true;
28
+ if (value === undefined)
29
+ return false;
30
+ const matchers = Array.isArray(matcher) ? matcher : [matcher];
31
+ return matchers.some((item) => wildcardToRegExp(item).test(value));
32
+ }
33
+ function matchRecord(input, matcher) {
34
+ if (!matcher)
35
+ return true;
36
+ return Object.entries(matcher).every(([path, expected]) => {
37
+ const actual = getPathValue(input, path);
38
+ if (typeof expected === "string" || Array.isArray(expected)) {
39
+ return matchString(actual === undefined ? undefined : String(actual), expected);
40
+ }
41
+ return actual === expected;
42
+ });
43
+ }
44
+ function eventMatchesFilter(event, filter) {
45
+ return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
46
+ }
47
+ function channelMatchesEvent(channel, event) {
48
+ if (!channel.enabled)
49
+ return false;
50
+ if (!channel.filters || channel.filters.length === 0)
51
+ return true;
52
+ return channel.filters.some((filter) => eventMatchesFilter(event, filter));
53
+ }
54
+
55
+ // src/storage.ts
56
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
57
+ import { existsSync } from "fs";
58
+ import { homedir } from "os";
59
+ import { join } from "path";
60
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
61
+ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
62
+ function getEventsDataDir(override) {
63
+ return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
64
+ }
65
+
66
+ class JsonEventsStore {
67
+ dataDir;
68
+ channelsPath;
69
+ eventsPath;
70
+ deliveriesPath;
71
+ constructor(dataDir = getEventsDataDir()) {
72
+ this.dataDir = dataDir;
73
+ this.channelsPath = join(dataDir, "channels.json");
74
+ this.eventsPath = join(dataDir, "events.json");
75
+ this.deliveriesPath = join(dataDir, "deliveries.json");
76
+ }
77
+ async init() {
78
+ await mkdir(this.dataDir, { recursive: true });
79
+ await this.ensureArrayFile(this.channelsPath);
80
+ await this.ensureArrayFile(this.eventsPath);
81
+ await this.ensureArrayFile(this.deliveriesPath);
82
+ }
83
+ async addChannel(channel) {
84
+ await this.init();
85
+ const channels = await this.readJson(this.channelsPath, []);
86
+ const index = channels.findIndex((item) => item.id === channel.id);
87
+ if (index >= 0) {
88
+ channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
89
+ } else {
90
+ channels.push(channel);
91
+ }
92
+ await this.writeJson(this.channelsPath, channels);
93
+ return index >= 0 ? channels[index] : channel;
94
+ }
95
+ async listChannels() {
96
+ await this.init();
97
+ return this.readJson(this.channelsPath, []);
98
+ }
99
+ async getChannel(id) {
100
+ const channels = await this.listChannels();
101
+ return channels.find((channel) => channel.id === id);
102
+ }
103
+ async removeChannel(id) {
104
+ await this.init();
105
+ const channels = await this.readJson(this.channelsPath, []);
106
+ const next = channels.filter((channel) => channel.id !== id);
107
+ await this.writeJson(this.channelsPath, next);
108
+ return next.length !== channels.length;
109
+ }
110
+ async appendEvent(event) {
111
+ await this.init();
112
+ const events = await this.readJson(this.eventsPath, []);
113
+ events.push(event);
114
+ await this.writeJson(this.eventsPath, events);
115
+ return event;
116
+ }
117
+ async listEvents() {
118
+ await this.init();
119
+ return this.readJson(this.eventsPath, []);
120
+ }
121
+ async findEventByIdentity(identity) {
122
+ const events = await this.listEvents();
123
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
124
+ }
125
+ async appendDelivery(result) {
126
+ await this.init();
127
+ const deliveries = await this.readJson(this.deliveriesPath, []);
128
+ deliveries.push(result);
129
+ await this.writeJson(this.deliveriesPath, deliveries);
130
+ return result;
131
+ }
132
+ async listDeliveries() {
133
+ await this.init();
134
+ return this.readJson(this.deliveriesPath, []);
135
+ }
136
+ async exportData() {
137
+ return {
138
+ channels: await this.listChannels(),
139
+ events: await this.listEvents(),
140
+ deliveries: await this.listDeliveries()
141
+ };
142
+ }
143
+ async ensureArrayFile(path) {
144
+ if (!existsSync(path)) {
145
+ await writeFile(path, `[]
146
+ `, "utf-8");
147
+ }
148
+ }
149
+ async readJson(path, fallback) {
150
+ try {
151
+ const raw = await readFile(path, "utf-8");
152
+ if (!raw.trim())
153
+ return fallback;
154
+ return JSON.parse(raw);
155
+ } catch (error) {
156
+ if (error.code === "ENOENT")
157
+ return fallback;
158
+ throw error;
159
+ }
160
+ }
161
+ async writeJson(path, value) {
162
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
163
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
164
+ `, "utf-8");
165
+ await rename(tempPath, path);
166
+ }
167
+ }
168
+
169
+ // src/transports.ts
170
+ import { randomUUID } from "crypto";
171
+ import { spawn } from "child_process";
172
+
173
+ // src/signing.ts
174
+ import { createHmac, timingSafeEqual } from "crypto";
175
+ function buildSignatureBase(timestamp, body) {
176
+ return `${timestamp}.${body}`;
177
+ }
178
+ function signPayload(secret, timestamp, body) {
179
+ const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
180
+ return `sha256=${digest}`;
181
+ }
182
+
183
+ // src/transports.ts
184
+ function now() {
185
+ return new Date().toISOString();
186
+ }
187
+ function truncate(value, max = 4096) {
188
+ return value.length > max ? `${value.slice(0, max)}...` : value;
189
+ }
190
+ function buildWebhookRequest(event, channel) {
191
+ if (!channel.webhook)
192
+ throw new Error(`Channel ${channel.id} has no webhook config`);
193
+ const body = JSON.stringify(event);
194
+ const timestamp = event.time;
195
+ const headers = {
196
+ "Content-Type": "application/json",
197
+ "User-Agent": "@hasna/events",
198
+ "X-Hasna-Event-Id": event.id,
199
+ "X-Hasna-Event-Type": event.type,
200
+ "X-Hasna-Timestamp": timestamp,
201
+ ...channel.webhook.headers
202
+ };
203
+ if (channel.webhook.secret) {
204
+ headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
205
+ }
206
+ return { body, headers };
207
+ }
208
+ async function dispatchWebhook(event, channel, options = {}) {
209
+ if (!channel.webhook)
210
+ throw new Error(`Channel ${channel.id} has no webhook config`);
211
+ const startedAt = now();
212
+ const { body, headers } = buildWebhookRequest(event, channel);
213
+ const controller = new AbortController;
214
+ const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
215
+ try {
216
+ const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
217
+ method: "POST",
218
+ headers,
219
+ body,
220
+ signal: controller.signal
221
+ });
222
+ const responseBody = truncate(await response.text());
223
+ return {
224
+ attempt: 1,
225
+ status: response.ok ? "success" : "failed",
226
+ startedAt,
227
+ completedAt: now(),
228
+ responseStatus: response.status,
229
+ responseBody,
230
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
231
+ };
232
+ } catch (error) {
233
+ return {
234
+ attempt: 1,
235
+ status: "failed",
236
+ startedAt,
237
+ completedAt: now(),
238
+ error: error instanceof Error ? error.message : String(error)
239
+ };
240
+ } finally {
241
+ clearTimeout(timeout);
242
+ }
243
+ }
244
+ async function dispatchCommand(event, channel) {
245
+ if (!channel.command)
246
+ throw new Error(`Channel ${channel.id} has no command config`);
247
+ const startedAt = now();
248
+ const eventJson = JSON.stringify(event);
249
+ const env = {
250
+ ...process.env,
251
+ ...channel.command.env,
252
+ HASNA_CHANNEL_ID: channel.id,
253
+ HASNA_EVENT_ID: event.id,
254
+ HASNA_EVENT_TYPE: event.type,
255
+ HASNA_EVENT_SOURCE: event.source,
256
+ HASNA_EVENT_SUBJECT: event.subject ?? "",
257
+ HASNA_EVENT_SEVERITY: event.severity,
258
+ HASNA_EVENT_TIME: event.time,
259
+ HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
260
+ HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
261
+ HASNA_EVENT_JSON: eventJson
262
+ };
263
+ return new Promise((resolve) => {
264
+ const child = spawn(channel.command.command, channel.command.args ?? [], {
265
+ cwd: channel.command.cwd,
266
+ env,
267
+ stdio: ["pipe", "pipe", "pipe"]
268
+ });
269
+ let stdout = "";
270
+ let stderr = "";
271
+ const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
272
+ child.stdin.end(eventJson);
273
+ child.stdout.on("data", (chunk) => {
274
+ stdout += chunk.toString();
275
+ });
276
+ child.stderr.on("data", (chunk) => {
277
+ stderr += chunk.toString();
278
+ });
279
+ child.on("error", (error) => {
280
+ clearTimeout(timeout);
281
+ resolve({
282
+ attempt: 1,
283
+ status: "failed",
284
+ startedAt,
285
+ completedAt: now(),
286
+ stdout: truncate(stdout),
287
+ stderr: truncate(stderr),
288
+ error: error.message
289
+ });
290
+ });
291
+ child.on("close", (code, signal) => {
292
+ clearTimeout(timeout);
293
+ const success = code === 0;
294
+ resolve({
295
+ attempt: 1,
296
+ status: success ? "success" : "failed",
297
+ startedAt,
298
+ completedAt: now(),
299
+ stdout: truncate(stdout),
300
+ stderr: truncate(stderr),
301
+ error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
302
+ });
303
+ });
304
+ });
305
+ }
306
+ async function dispatchChannel(event, channel, options = {}) {
307
+ if (channel.transport === "webhook")
308
+ return dispatchWebhook(event, channel, options);
309
+ if (channel.transport === "command")
310
+ return dispatchCommand(event, channel);
311
+ return {
312
+ attempt: 1,
313
+ status: "skipped",
314
+ startedAt: now(),
315
+ completedAt: now(),
316
+ error: `Unsupported transport: ${channel.transport}`
317
+ };
318
+ }
319
+ function createDeliveryResult(event, channel, attempts) {
320
+ const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
321
+ return {
322
+ id: randomUUID(),
323
+ eventId: event.id,
324
+ channelId: channel.id,
325
+ transport: channel.transport,
326
+ status,
327
+ attempts,
328
+ createdAt: attempts[0]?.startedAt ?? now(),
329
+ completedAt: attempts.at(-1)?.completedAt ?? now()
330
+ };
331
+ }
332
+
333
+ // src/index.ts
334
+ function createEvent(input) {
335
+ return {
336
+ id: input.id ?? randomUUID2(),
337
+ source: input.source,
338
+ type: input.type,
339
+ time: normalizeTime(input.time),
340
+ subject: input.subject,
341
+ severity: input.severity ?? "info",
342
+ data: input.data ?? {},
343
+ message: input.message,
344
+ dedupeKey: input.dedupeKey,
345
+ schemaVersion: input.schemaVersion ?? "1.0",
346
+ metadata: input.metadata ?? {}
347
+ };
348
+ }
349
+
350
+ class EventsClient {
351
+ store;
352
+ redactors;
353
+ transportOptions;
354
+ constructor(options = {}) {
355
+ this.store = options.store ?? new JsonEventsStore(options.dataDir);
356
+ this.redactors = options.redactors ?? [];
357
+ this.transportOptions = { fetchImpl: options.fetchImpl };
358
+ }
359
+ async addChannel(input) {
360
+ const timestamp = new Date().toISOString();
361
+ return this.store.addChannel({
362
+ ...input,
363
+ createdAt: input.createdAt ?? timestamp,
364
+ updatedAt: input.updatedAt ?? timestamp
365
+ });
366
+ }
367
+ async listChannels() {
368
+ return this.store.listChannels();
369
+ }
370
+ async removeChannel(id) {
371
+ return this.store.removeChannel(id);
372
+ }
373
+ async emit(input, options = {}) {
374
+ const event = createEvent(input);
375
+ if (options.dedupe !== false) {
376
+ const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
377
+ if (existing) {
378
+ return { event: existing, deliveries: [], deduped: true };
379
+ }
380
+ }
381
+ await this.store.appendEvent(event);
382
+ const deliveries = options.deliver === false ? [] : await this.deliver(event);
383
+ return { event, deliveries, deduped: false };
384
+ }
385
+ async listEvents() {
386
+ return this.store.listEvents();
387
+ }
388
+ async listDeliveries() {
389
+ return this.store.listDeliveries();
390
+ }
391
+ async deliver(event) {
392
+ const channels = await this.store.listChannels();
393
+ const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
394
+ const deliveries = [];
395
+ for (const channel of selected) {
396
+ const eventForChannel = await this.applyRedaction(event, channel);
397
+ const result = await this.deliverWithRetry(eventForChannel, channel);
398
+ await this.store.appendDelivery(result);
399
+ deliveries.push(result);
400
+ }
401
+ return deliveries;
402
+ }
403
+ async testChannel(id, input = {}) {
404
+ const channel = await this.store.getChannel(id);
405
+ if (!channel)
406
+ throw new Error(`Channel not found: ${id}`);
407
+ const event = createEvent({
408
+ source: input.source ?? "hasna.events",
409
+ type: input.type ?? "events.test",
410
+ subject: input.subject ?? id,
411
+ severity: input.severity ?? "info",
412
+ data: input.data ?? { test: true },
413
+ message: input.message ?? "Hasna events test delivery",
414
+ dedupeKey: input.dedupeKey,
415
+ schemaVersion: input.schemaVersion,
416
+ metadata: input.metadata,
417
+ time: input.time,
418
+ id: input.id
419
+ });
420
+ const eventForChannel = await this.applyRedaction(event, channel);
421
+ const result = await this.deliverWithRetry(eventForChannel, channel);
422
+ await this.store.appendDelivery(result);
423
+ return result;
424
+ }
425
+ async replay(options = {}) {
426
+ const events = (await this.store.listEvents()).filter((event) => {
427
+ if (options.eventId && event.id !== options.eventId)
428
+ return false;
429
+ if (options.source && event.source !== options.source)
430
+ return false;
431
+ if (options.type && event.type !== options.type)
432
+ return false;
433
+ return true;
434
+ });
435
+ if (options.dryRun)
436
+ return { events, deliveries: [] };
437
+ const deliveries = [];
438
+ for (const event of events) {
439
+ deliveries.push(...await this.deliver(event));
440
+ }
441
+ return { events, deliveries };
442
+ }
443
+ async applyRedaction(event, channel) {
444
+ let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
445
+ for (const redactor of this.redactors) {
446
+ next = await redactor(next, channel);
447
+ }
448
+ return next;
449
+ }
450
+ async deliverWithRetry(event, channel) {
451
+ const policy = normalizeRetryPolicy(channel.retry);
452
+ const attempts = [];
453
+ for (let index = 0;index < policy.maxAttempts; index += 1) {
454
+ const attempt = await dispatchChannel(event, channel, this.transportOptions);
455
+ attempt.attempt = index + 1;
456
+ if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
457
+ attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
458
+ }
459
+ attempts.push(attempt);
460
+ if (attempt.status !== "failed")
461
+ break;
462
+ if (attempt.nextBackoffMs)
463
+ await Bun.sleep(attempt.nextBackoffMs);
464
+ }
465
+ return createDeliveryResult(event, channel, attempts);
466
+ }
467
+ }
468
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
469
+ if (paths.length === 0)
470
+ return event;
471
+ const copy = structuredClone(event);
472
+ for (const path of paths) {
473
+ setPath(copy, path, replacement);
474
+ }
475
+ return copy;
476
+ }
477
+ function sanitizeChannelForOutput(channel) {
478
+ const copy = structuredClone(channel);
479
+ if (copy.webhook?.secret)
480
+ copy.webhook.secret = "[REDACTED]";
481
+ if (copy.command?.env) {
482
+ copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
483
+ }
484
+ return copy;
485
+ }
486
+ function sanitizeChannelsForOutput(channels) {
487
+ return channels.map(sanitizeChannelForOutput);
488
+ }
489
+ function shouldRedactKey(key) {
490
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
491
+ }
492
+ function setPath(input, path, replacement) {
493
+ const parts = path.split(".");
494
+ let cursor = input;
495
+ for (const part of parts.slice(0, -1)) {
496
+ const next = cursor[part];
497
+ if (!next || typeof next !== "object")
498
+ return;
499
+ cursor = next;
500
+ }
501
+ const last = parts.at(-1);
502
+ if (last && last in cursor)
503
+ cursor[last] = replacement;
504
+ }
505
+ function normalizeTime(value) {
506
+ if (!value)
507
+ return new Date().toISOString();
508
+ return value instanceof Date ? value.toISOString() : value;
509
+ }
510
+ function normalizeRetryPolicy(policy) {
511
+ return {
512
+ maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
513
+ backoffMs: Math.max(0, policy?.backoffMs ?? 250),
514
+ multiplier: Math.max(1, policy?.multiplier ?? 2)
515
+ };
516
+ }
517
+
518
+ // src/cli/index.ts
519
+ function version() {
520
+ try {
521
+ const packagePath = join2(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
522
+ return JSON.parse(readFileSync(packagePath, "utf-8")).version ?? "0.0.0";
523
+ } catch {
524
+ return "0.0.0";
525
+ }
526
+ }
527
+ function parseGlobalArgs(argv) {
528
+ const rest = [];
529
+ let json = false;
530
+ let dir;
531
+ for (let index = 0;index < argv.length; index += 1) {
532
+ const arg = argv[index];
533
+ if (arg === "--json" || arg === "-j") {
534
+ json = true;
535
+ } else if (arg === "--dir") {
536
+ dir = argv[++index];
537
+ } else {
538
+ rest.push(arg);
539
+ }
540
+ }
541
+ return { json, dir, rest };
542
+ }
543
+ function takeOption(args, name) {
544
+ const index = args.indexOf(name);
545
+ if (index === -1)
546
+ return;
547
+ const value = args[index + 1];
548
+ args.splice(index, 2);
549
+ return value;
550
+ }
551
+ function takeFlag(args, name) {
552
+ const index = args.indexOf(name);
553
+ if (index === -1)
554
+ return false;
555
+ args.splice(index, 1);
556
+ return true;
557
+ }
558
+ function takeMany(args, name) {
559
+ const values = [];
560
+ while (args.includes(name)) {
561
+ const value = takeOption(args, name);
562
+ if (value !== undefined)
563
+ values.push(value);
564
+ }
565
+ return values;
566
+ }
567
+ function parseJsonOption(value, fallback) {
568
+ if (!value)
569
+ return fallback;
570
+ const parsed = JSON.parse(value);
571
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
572
+ throw new Error("Expected a JSON object");
573
+ }
574
+ return parsed;
575
+ }
576
+ function parseFilter(args) {
577
+ const filter2 = {};
578
+ const type = takeOption(args, "--type") ?? takeOption(args, "--event-type");
579
+ const source = takeOption(args, "--source");
580
+ const subject = takeOption(args, "--subject");
581
+ const severity = takeOption(args, "--severity");
582
+ if (type)
583
+ filter2.type = type;
584
+ if (source)
585
+ filter2.source = source;
586
+ if (subject)
587
+ filter2.subject = subject;
588
+ if (severity)
589
+ filter2.severity = severity;
590
+ return Object.keys(filter2).length > 0 ? [filter2] : undefined;
591
+ }
592
+ function parseHeaders(values) {
593
+ if (values.length === 0)
594
+ return;
595
+ const headers = {};
596
+ for (const value of values) {
597
+ const separator = value.indexOf("=");
598
+ if (separator === -1)
599
+ throw new Error(`Invalid header, expected name=value: ${value}`);
600
+ headers[value.slice(0, separator)] = value.slice(separator + 1);
601
+ }
602
+ return headers;
603
+ }
604
+ function output(parsed, value, human) {
605
+ if (parsed.json) {
606
+ console.log(JSON.stringify(value, null, 2));
607
+ return;
608
+ }
609
+ human();
610
+ }
611
+ function printHelp() {
612
+ console.log(`events ${version()}
613
+
614
+ Usage:
615
+ events [--dir <path>] [--json] webhooks add <url|command> [options]
616
+ events [--dir <path>] [--json] webhooks list
617
+ events [--dir <path>] [--json] webhooks remove <id>
618
+ events [--dir <path>] [--json] webhooks test <id>
619
+ events [--dir <path>] [--json] events emit <type> --source <source> [options]
620
+ events [--dir <path>] [--json] events list [--limit <n>]
621
+ events [--dir <path>] [--json] events replay [--id <event-id>] [--dry-run]
622
+
623
+ Environment:
624
+ HASNA_EVENTS_DIR or HASNA_EVENTS_HOME overrides the default ${getEventsDataDir()}`);
625
+ }
626
+ async function main(argv = process.argv.slice(2)) {
627
+ const parsed = parseGlobalArgs(argv);
628
+ const [group, command, ...tail] = parsed.rest;
629
+ if (!group || group === "--help" || group === "-h") {
630
+ printHelp();
631
+ return;
632
+ }
633
+ if (group === "--version" || group === "-v") {
634
+ console.log(version());
635
+ return;
636
+ }
637
+ const store = new JsonEventsStore(parsed.dir);
638
+ const client = new EventsClient({ store });
639
+ if (group === "webhooks") {
640
+ await handleWebhooks(client, command, tail, parsed);
641
+ return;
642
+ }
643
+ if (group === "events") {
644
+ await handleEvents(client, command, tail, parsed);
645
+ return;
646
+ }
647
+ throw new Error(`Unknown command group: ${group}`);
648
+ }
649
+ async function handleWebhooks(client, command, tail, parsed) {
650
+ if (command === "add") {
651
+ const args = [...tail];
652
+ const transport = takeOption(args, "--transport") ?? "webhook";
653
+ const id = takeOption(args, "--id") ?? crypto.randomUUID();
654
+ const name = takeOption(args, "--name");
655
+ const secret = takeOption(args, "--secret");
656
+ const timeoutMs = numberOption(takeOption(args, "--timeout-ms"));
657
+ const retryAttempts = numberOption(takeOption(args, "--retry-attempts"));
658
+ const retryBackoffMs = numberOption(takeOption(args, "--retry-backoff-ms"));
659
+ const disabled = takeFlag(args, "--disabled");
660
+ const headerValues = takeMany(args, "--header");
661
+ const redactions = takeMany(args, "--redact");
662
+ const filters = parseFilter(args);
663
+ const target = args[0];
664
+ if (!target)
665
+ throw new Error("webhooks add requires a URL or command target");
666
+ const now2 = new Date().toISOString();
667
+ const channel = {
668
+ id,
669
+ name,
670
+ enabled: !disabled,
671
+ transport,
672
+ filters,
673
+ retry: retryAttempts || retryBackoffMs ? { maxAttempts: retryAttempts, backoffMs: retryBackoffMs } : undefined,
674
+ redact: redactions.length > 0 ? { paths: redactions } : undefined,
675
+ createdAt: now2,
676
+ updatedAt: now2
677
+ };
678
+ if (transport === "webhook") {
679
+ channel.webhook = { url: target, secret, headers: parseHeaders(headerValues), timeoutMs };
680
+ } else if (transport === "command") {
681
+ channel.command = { command: target, args: args.slice(1), timeoutMs };
682
+ } else {
683
+ throw new Error(`Transport ${transport} is reserved for future use and cannot be added yet`);
684
+ }
685
+ const saved = await client.addChannel(channel);
686
+ output(parsed, sanitizeChannelForOutput(saved), () => console.log(`Added ${saved.transport} channel ${saved.id}`));
687
+ return;
688
+ }
689
+ if (command === "list") {
690
+ const channels = await client.listChannels();
691
+ output(parsed, sanitizeChannelsForOutput(channels), () => {
692
+ if (channels.length === 0) {
693
+ console.log("No channels configured.");
694
+ return;
695
+ }
696
+ for (const channel of channels) {
697
+ const target = channel.webhook?.url ?? channel.command?.command ?? channel.transport;
698
+ console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${target}`);
699
+ }
700
+ });
701
+ return;
702
+ }
703
+ if (command === "remove") {
704
+ const id = tail[0];
705
+ if (!id)
706
+ throw new Error("webhooks remove requires a channel id");
707
+ const removed = await client.removeChannel(id);
708
+ output(parsed, { removed }, () => console.log(removed ? `Removed ${id}` : `Channel not found: ${id}`));
709
+ return;
710
+ }
711
+ if (command === "test") {
712
+ const args = [...tail];
713
+ const id = args.shift();
714
+ if (!id)
715
+ throw new Error("webhooks test requires a channel id");
716
+ const result = await client.testChannel(id, {
717
+ source: takeOption(args, "--source") ?? "hasna.events",
718
+ type: takeOption(args, "--type") ?? "events.test",
719
+ subject: takeOption(args, "--subject") ?? id,
720
+ data: parseJsonOption(takeOption(args, "--data"), { test: true })
721
+ });
722
+ output(parsed, result, () => console.log(`${result.status}: ${result.channelId}`));
723
+ return;
724
+ }
725
+ throw new Error(`Unknown webhooks command: ${command ?? ""}`);
726
+ }
727
+ async function handleEvents(client, command, tail, parsed) {
728
+ if (command === "emit") {
729
+ const args = [...tail];
730
+ const type = args.shift();
731
+ if (!type)
732
+ throw new Error("events emit requires an event type");
733
+ const source = takeOption(args, "--source");
734
+ if (!source)
735
+ throw new Error("events emit requires --source");
736
+ const noDeliver = takeFlag(args, "--no-deliver");
737
+ const result = await client.emit({
738
+ type,
739
+ source,
740
+ subject: takeOption(args, "--subject"),
741
+ severity: severityOption(takeOption(args, "--severity")),
742
+ message: takeOption(args, "--message"),
743
+ dedupeKey: takeOption(args, "--dedupe-key"),
744
+ data: parseJsonOption(takeOption(args, "--data"), {}),
745
+ metadata: parseJsonOption(takeOption(args, "--metadata"), {})
746
+ }, { deliver: !noDeliver });
747
+ output(parsed, result, () => console.log(`${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`));
748
+ return;
749
+ }
750
+ if (command === "list") {
751
+ const args = [...tail];
752
+ const limit = numberOption(takeOption(args, "--limit"));
753
+ const type = takeOption(args, "--type");
754
+ const source = takeOption(args, "--source");
755
+ let events = await client.listEvents();
756
+ if (type)
757
+ events = events.filter((event) => event.type === type);
758
+ if (source)
759
+ events = events.filter((event) => event.source === source);
760
+ if (limit)
761
+ events = events.slice(-limit);
762
+ output(parsed, events, () => {
763
+ if (events.length === 0) {
764
+ console.log("No events recorded.");
765
+ return;
766
+ }
767
+ for (const event of events) {
768
+ console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
769
+ }
770
+ });
771
+ return;
772
+ }
773
+ if (command === "replay") {
774
+ const args = [...tail];
775
+ const result = await client.replay({
776
+ eventId: takeOption(args, "--id"),
777
+ source: takeOption(args, "--source"),
778
+ type: takeOption(args, "--type"),
779
+ dryRun: takeFlag(args, "--dry-run")
780
+ });
781
+ output(parsed, result, () => console.log(`Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`));
782
+ return;
783
+ }
784
+ throw new Error(`Unknown events command: ${command ?? ""}`);
785
+ }
786
+ function numberOption(value) {
787
+ if (value === undefined)
788
+ return;
789
+ const parsed = Number(value);
790
+ if (!Number.isFinite(parsed))
791
+ throw new Error(`Expected a number, got ${value}`);
792
+ return parsed;
793
+ }
794
+ function severityOption(value) {
795
+ if (!value)
796
+ return;
797
+ const allowed = new Set(["debug", "info", "notice", "warning", "error", "critical"]);
798
+ if (!allowed.has(value))
799
+ throw new Error(`Invalid severity: ${value}`);
800
+ return value;
801
+ }
802
+ main().catch((error) => {
803
+ const parsed = parseGlobalArgs(process.argv.slice(2));
804
+ const message = error instanceof Error ? error.message : String(error);
805
+ if (parsed.json) {
806
+ console.log(JSON.stringify({ error: message }, null, 2));
807
+ } else {
808
+ console.error(message);
809
+ }
810
+ process.exit(1);
811
+ });