@hasna/machines 0.0.27 → 0.0.28

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/index.js CHANGED
@@ -13427,6 +13427,526 @@ function listPorts(machineId) {
13427
13427
  listeners: parsePortOutput(result.stdout, format)
13428
13428
  };
13429
13429
  }
13430
+ // node_modules/@hasna/events/dist/index.js
13431
+ import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
13432
+ import { existsSync as existsSync7 } from "fs";
13433
+ import { homedir as homedir4 } from "os";
13434
+ import { join as join6 } from "path";
13435
+ import { createHmac, timingSafeEqual } from "crypto";
13436
+ import { randomUUID } from "crypto";
13437
+ import { spawn } from "child_process";
13438
+ import { randomUUID as randomUUID2 } from "crypto";
13439
+ function getPathValue(input, path) {
13440
+ return path.split(".").reduce((value, part) => {
13441
+ if (value && typeof value === "object" && part in value) {
13442
+ return value[part];
13443
+ }
13444
+ return;
13445
+ }, input);
13446
+ }
13447
+ function wildcardToRegExp(pattern) {
13448
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
13449
+ return new RegExp(`^${escaped}$`);
13450
+ }
13451
+ function matchString(value, matcher) {
13452
+ if (matcher === undefined)
13453
+ return true;
13454
+ if (value === undefined)
13455
+ return false;
13456
+ const matchers = Array.isArray(matcher) ? matcher : [matcher];
13457
+ return matchers.some((item) => wildcardToRegExp(item).test(value));
13458
+ }
13459
+ function matchRecord(input, matcher) {
13460
+ if (!matcher)
13461
+ return true;
13462
+ return Object.entries(matcher).every(([path, expected]) => {
13463
+ const actual = getPathValue(input, path);
13464
+ if (typeof expected === "string" || Array.isArray(expected)) {
13465
+ return matchString(actual === undefined ? undefined : String(actual), expected);
13466
+ }
13467
+ return actual === expected;
13468
+ });
13469
+ }
13470
+ function eventMatchesFilter(event, filter) {
13471
+ 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);
13472
+ }
13473
+ function channelMatchesEvent(channel, event) {
13474
+ if (!channel.enabled)
13475
+ return false;
13476
+ if (!channel.filters || channel.filters.length === 0)
13477
+ return true;
13478
+ return channel.filters.some((filter) => eventMatchesFilter(event, filter));
13479
+ }
13480
+ var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
13481
+ var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
13482
+ function getEventsDataDir(override) {
13483
+ return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join6(homedir4(), ".hasna", "events");
13484
+ }
13485
+
13486
+ class JsonEventsStore {
13487
+ dataDir;
13488
+ channelsPath;
13489
+ eventsPath;
13490
+ deliveriesPath;
13491
+ constructor(dataDir = getEventsDataDir()) {
13492
+ this.dataDir = dataDir;
13493
+ this.channelsPath = join6(dataDir, "channels.json");
13494
+ this.eventsPath = join6(dataDir, "events.json");
13495
+ this.deliveriesPath = join6(dataDir, "deliveries.json");
13496
+ }
13497
+ async init() {
13498
+ await mkdir(this.dataDir, { recursive: true, mode: 448 });
13499
+ await chmod(this.dataDir, 448).catch(() => {
13500
+ return;
13501
+ });
13502
+ await this.ensureArrayFile(this.channelsPath);
13503
+ await this.ensureArrayFile(this.eventsPath);
13504
+ await this.ensureArrayFile(this.deliveriesPath);
13505
+ }
13506
+ async addChannel(channel) {
13507
+ await this.init();
13508
+ const channels = await this.readJson(this.channelsPath, []);
13509
+ const index = channels.findIndex((item) => item.id === channel.id);
13510
+ if (index >= 0) {
13511
+ channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
13512
+ } else {
13513
+ channels.push(channel);
13514
+ }
13515
+ await this.writeJson(this.channelsPath, channels);
13516
+ return index >= 0 ? channels[index] : channel;
13517
+ }
13518
+ async listChannels() {
13519
+ await this.init();
13520
+ return this.readJson(this.channelsPath, []);
13521
+ }
13522
+ async getChannel(id) {
13523
+ const channels = await this.listChannels();
13524
+ return channels.find((channel) => channel.id === id);
13525
+ }
13526
+ async removeChannel(id) {
13527
+ await this.init();
13528
+ const channels = await this.readJson(this.channelsPath, []);
13529
+ const next = channels.filter((channel) => channel.id !== id);
13530
+ await this.writeJson(this.channelsPath, next);
13531
+ return next.length !== channels.length;
13532
+ }
13533
+ async appendEvent(event) {
13534
+ await this.init();
13535
+ const events = await this.readJson(this.eventsPath, []);
13536
+ events.push(event);
13537
+ await this.writeJson(this.eventsPath, events);
13538
+ return event;
13539
+ }
13540
+ async listEvents() {
13541
+ await this.init();
13542
+ return this.readJson(this.eventsPath, []);
13543
+ }
13544
+ async findEventByIdentity(identity) {
13545
+ const events = await this.listEvents();
13546
+ return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
13547
+ }
13548
+ async appendDelivery(result) {
13549
+ await this.init();
13550
+ const deliveries = await this.readJson(this.deliveriesPath, []);
13551
+ deliveries.push(result);
13552
+ await this.writeJson(this.deliveriesPath, deliveries);
13553
+ return result;
13554
+ }
13555
+ async listDeliveries() {
13556
+ await this.init();
13557
+ return this.readJson(this.deliveriesPath, []);
13558
+ }
13559
+ async exportData() {
13560
+ return {
13561
+ channels: await this.listChannels(),
13562
+ events: await this.listEvents(),
13563
+ deliveries: await this.listDeliveries()
13564
+ };
13565
+ }
13566
+ async ensureArrayFile(path) {
13567
+ if (!existsSync7(path)) {
13568
+ await writeFile(path, `[]
13569
+ `, { encoding: "utf-8", mode: 384 });
13570
+ }
13571
+ await chmod(path, 384).catch(() => {
13572
+ return;
13573
+ });
13574
+ }
13575
+ async readJson(path, fallback) {
13576
+ try {
13577
+ const raw = await readFile(path, "utf-8");
13578
+ if (!raw.trim())
13579
+ return fallback;
13580
+ return JSON.parse(raw);
13581
+ } catch (error) {
13582
+ if (error.code === "ENOENT")
13583
+ return fallback;
13584
+ throw error;
13585
+ }
13586
+ }
13587
+ async writeJson(path, value) {
13588
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
13589
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
13590
+ `, { encoding: "utf-8", mode: 384 });
13591
+ await rename(tempPath, path);
13592
+ await chmod(path, 384).catch(() => {
13593
+ return;
13594
+ });
13595
+ }
13596
+ }
13597
+ var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
13598
+ function buildSignatureBase(timestamp, body) {
13599
+ return `${timestamp}.${body}`;
13600
+ }
13601
+ function signPayload(secret, timestamp, body) {
13602
+ const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
13603
+ return `sha256=${digest}`;
13604
+ }
13605
+ function now() {
13606
+ return new Date().toISOString();
13607
+ }
13608
+ function truncate(value, max = 4096) {
13609
+ return value.length > max ? `${value.slice(0, max)}...` : value;
13610
+ }
13611
+ function buildWebhookRequest(event, channel) {
13612
+ if (!channel.webhook)
13613
+ throw new Error(`Channel ${channel.id} has no webhook config`);
13614
+ const body = JSON.stringify(event);
13615
+ const timestamp = event.time;
13616
+ const headers = {
13617
+ "Content-Type": "application/json",
13618
+ "User-Agent": "@hasna/events",
13619
+ "X-Hasna-Event-Id": event.id,
13620
+ "X-Hasna-Event-Type": event.type,
13621
+ "X-Hasna-Timestamp": timestamp,
13622
+ ...channel.webhook.headers
13623
+ };
13624
+ if (channel.webhook.secret) {
13625
+ headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
13626
+ }
13627
+ return { body, headers };
13628
+ }
13629
+ async function dispatchWebhook2(event, channel, options = {}) {
13630
+ if (!channel.webhook)
13631
+ throw new Error(`Channel ${channel.id} has no webhook config`);
13632
+ const startedAt = now();
13633
+ const { body, headers } = buildWebhookRequest(event, channel);
13634
+ const controller = new AbortController;
13635
+ const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
13636
+ try {
13637
+ const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
13638
+ method: "POST",
13639
+ headers,
13640
+ body,
13641
+ signal: controller.signal
13642
+ });
13643
+ const responseBody = truncate(await response.text());
13644
+ return {
13645
+ attempt: 1,
13646
+ status: response.ok ? "success" : "failed",
13647
+ startedAt,
13648
+ completedAt: now(),
13649
+ responseStatus: response.status,
13650
+ responseBody,
13651
+ error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
13652
+ };
13653
+ } catch (error) {
13654
+ return {
13655
+ attempt: 1,
13656
+ status: "failed",
13657
+ startedAt,
13658
+ completedAt: now(),
13659
+ error: error instanceof Error ? error.message : String(error)
13660
+ };
13661
+ } finally {
13662
+ clearTimeout(timeout);
13663
+ }
13664
+ }
13665
+ async function dispatchCommand2(event, channel) {
13666
+ if (!channel.command)
13667
+ throw new Error(`Channel ${channel.id} has no command config`);
13668
+ const startedAt = now();
13669
+ const eventJson = JSON.stringify(event);
13670
+ const env = {
13671
+ ...process.env,
13672
+ ...channel.command.env,
13673
+ HASNA_CHANNEL_ID: channel.id,
13674
+ HASNA_EVENT_ID: event.id,
13675
+ HASNA_EVENT_TYPE: event.type,
13676
+ HASNA_EVENT_SOURCE: event.source,
13677
+ HASNA_EVENT_SUBJECT: event.subject ?? "",
13678
+ HASNA_EVENT_SEVERITY: event.severity,
13679
+ HASNA_EVENT_TIME: event.time,
13680
+ HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
13681
+ HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
13682
+ HASNA_EVENT_JSON: eventJson
13683
+ };
13684
+ return new Promise((resolve2) => {
13685
+ const child = spawn(channel.command.command, channel.command.args ?? [], {
13686
+ cwd: channel.command.cwd,
13687
+ env,
13688
+ stdio: ["pipe", "pipe", "pipe"]
13689
+ });
13690
+ let stdout = "";
13691
+ let stderr = "";
13692
+ const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
13693
+ child.stdin.end(eventJson);
13694
+ child.stdout.on("data", (chunk) => {
13695
+ stdout += chunk.toString();
13696
+ });
13697
+ child.stderr.on("data", (chunk) => {
13698
+ stderr += chunk.toString();
13699
+ });
13700
+ child.on("error", (error) => {
13701
+ clearTimeout(timeout);
13702
+ resolve2({
13703
+ attempt: 1,
13704
+ status: "failed",
13705
+ startedAt,
13706
+ completedAt: now(),
13707
+ stdout: truncate(stdout),
13708
+ stderr: truncate(stderr),
13709
+ error: error.message
13710
+ });
13711
+ });
13712
+ child.on("close", (code, signal) => {
13713
+ clearTimeout(timeout);
13714
+ const success = code === 0;
13715
+ resolve2({
13716
+ attempt: 1,
13717
+ status: success ? "success" : "failed",
13718
+ startedAt,
13719
+ completedAt: now(),
13720
+ stdout: truncate(stdout),
13721
+ stderr: truncate(stderr),
13722
+ error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
13723
+ });
13724
+ });
13725
+ });
13726
+ }
13727
+ async function dispatchChannel2(event, channel, options = {}) {
13728
+ if (channel.transport === "webhook")
13729
+ return dispatchWebhook2(event, channel, options);
13730
+ if (channel.transport === "command")
13731
+ return dispatchCommand2(event, channel);
13732
+ return {
13733
+ attempt: 1,
13734
+ status: "skipped",
13735
+ startedAt: now(),
13736
+ completedAt: now(),
13737
+ error: `Unsupported transport: ${channel.transport}`
13738
+ };
13739
+ }
13740
+ function createDeliveryResult(event, channel, attempts) {
13741
+ const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
13742
+ return {
13743
+ id: randomUUID(),
13744
+ eventId: event.id,
13745
+ channelId: channel.id,
13746
+ transport: channel.transport,
13747
+ status,
13748
+ attempts,
13749
+ createdAt: attempts[0]?.startedAt ?? now(),
13750
+ completedAt: attempts.at(-1)?.completedAt ?? now()
13751
+ };
13752
+ }
13753
+ function createEvent(input) {
13754
+ return {
13755
+ id: input.id ?? randomUUID2(),
13756
+ source: input.source,
13757
+ type: input.type,
13758
+ time: normalizeTime(input.time),
13759
+ subject: input.subject,
13760
+ severity: input.severity ?? "info",
13761
+ data: input.data ?? {},
13762
+ message: input.message,
13763
+ dedupeKey: input.dedupeKey,
13764
+ schemaVersion: input.schemaVersion ?? "1.0",
13765
+ metadata: input.metadata ?? {}
13766
+ };
13767
+ }
13768
+
13769
+ class EventsClient {
13770
+ store;
13771
+ redactors;
13772
+ transportOptions;
13773
+ constructor(options = {}) {
13774
+ this.store = options.store ?? new JsonEventsStore(options.dataDir);
13775
+ this.redactors = options.redactors ?? [];
13776
+ this.transportOptions = { fetchImpl: options.fetchImpl };
13777
+ }
13778
+ async addChannel(input) {
13779
+ const timestamp = new Date().toISOString();
13780
+ return this.store.addChannel({
13781
+ ...input,
13782
+ createdAt: input.createdAt ?? timestamp,
13783
+ updatedAt: input.updatedAt ?? timestamp
13784
+ });
13785
+ }
13786
+ async listChannels() {
13787
+ return this.store.listChannels();
13788
+ }
13789
+ async removeChannel(id) {
13790
+ return this.store.removeChannel(id);
13791
+ }
13792
+ async emit(input, options = {}) {
13793
+ const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
13794
+ if (options.dedupe !== false) {
13795
+ const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
13796
+ if (existing) {
13797
+ return { event: existing, deliveries: [], deduped: true };
13798
+ }
13799
+ }
13800
+ await this.store.appendEvent(event);
13801
+ const deliveries = options.deliver === false ? [] : await this.deliver(event);
13802
+ return { event, deliveries, deduped: false };
13803
+ }
13804
+ async listEvents() {
13805
+ return this.store.listEvents();
13806
+ }
13807
+ async listDeliveries() {
13808
+ return this.store.listDeliveries();
13809
+ }
13810
+ async deliver(event) {
13811
+ const channels = await this.store.listChannels();
13812
+ const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
13813
+ const deliveries = [];
13814
+ for (const channel of selected) {
13815
+ const eventForChannel = await this.applyRedaction(event, channel);
13816
+ const result = await this.deliverWithRetry(eventForChannel, channel);
13817
+ await this.store.appendDelivery(result);
13818
+ deliveries.push(result);
13819
+ }
13820
+ return deliveries;
13821
+ }
13822
+ async testChannel(id, input = {}) {
13823
+ const channel = await this.store.getChannel(id);
13824
+ if (!channel)
13825
+ throw new Error(`Channel not found: ${id}`);
13826
+ const event = createEvent({
13827
+ source: input.source ?? "hasna.events",
13828
+ type: input.type ?? "events.test",
13829
+ subject: input.subject ?? id,
13830
+ severity: input.severity ?? "info",
13831
+ data: input.data ?? { test: true },
13832
+ message: input.message ?? "Hasna events test delivery",
13833
+ dedupeKey: input.dedupeKey,
13834
+ schemaVersion: input.schemaVersion,
13835
+ metadata: input.metadata,
13836
+ time: input.time,
13837
+ id: input.id
13838
+ });
13839
+ const eventForChannel = await this.applyRedaction(event, channel);
13840
+ const result = await this.deliverWithRetry(eventForChannel, channel);
13841
+ await this.store.appendDelivery(result);
13842
+ return result;
13843
+ }
13844
+ async replay(options = {}) {
13845
+ const events = (await this.store.listEvents()).filter((event) => {
13846
+ if (options.eventId && event.id !== options.eventId)
13847
+ return false;
13848
+ if (options.source && event.source !== options.source)
13849
+ return false;
13850
+ if (options.type && event.type !== options.type)
13851
+ return false;
13852
+ return true;
13853
+ });
13854
+ if (options.dryRun)
13855
+ return { events, deliveries: [] };
13856
+ const deliveries = [];
13857
+ for (const event of events) {
13858
+ deliveries.push(...await this.deliver(event));
13859
+ }
13860
+ return { events, deliveries };
13861
+ }
13862
+ async applyRedaction(event, channel) {
13863
+ let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
13864
+ for (const redactor of this.redactors) {
13865
+ next = await redactor(next, channel);
13866
+ }
13867
+ return next;
13868
+ }
13869
+ async deliverWithRetry(event, channel) {
13870
+ const policy = normalizeRetryPolicy(channel.retry);
13871
+ const attempts = [];
13872
+ for (let index = 0;index < policy.maxAttempts; index += 1) {
13873
+ const attempt = await dispatchChannel2(event, channel, this.transportOptions);
13874
+ attempt.attempt = index + 1;
13875
+ if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
13876
+ attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
13877
+ }
13878
+ attempts.push(attempt);
13879
+ if (attempt.status !== "failed")
13880
+ break;
13881
+ if (attempt.nextBackoffMs)
13882
+ await Bun.sleep(attempt.nextBackoffMs);
13883
+ }
13884
+ return createDeliveryResult(event, channel, attempts);
13885
+ }
13886
+ }
13887
+ function redactPaths(event, paths, replacement = "[REDACTED]") {
13888
+ if (paths.length === 0)
13889
+ return event;
13890
+ const copy = structuredClone(event);
13891
+ for (const path of paths) {
13892
+ setPath(copy, path, replacement);
13893
+ }
13894
+ return copy;
13895
+ }
13896
+ function sanitizeChannelForOutput(channel) {
13897
+ const copy = structuredClone(channel);
13898
+ if (copy.webhook?.secret)
13899
+ copy.webhook.secret = "[REDACTED]";
13900
+ if (copy.command?.env) {
13901
+ copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
13902
+ }
13903
+ return copy;
13904
+ }
13905
+ function sanitizeChannelsForOutput(channels) {
13906
+ return channels.map(sanitizeChannelForOutput);
13907
+ }
13908
+ function redactSensitiveKeys(event, replacement = "[REDACTED]") {
13909
+ return redactValue(event, replacement);
13910
+ }
13911
+ function shouldRedactKey(key) {
13912
+ return /secret|token|password|api[_-]?key|authorization/i.test(key);
13913
+ }
13914
+ function redactValue(value, replacement) {
13915
+ if (Array.isArray(value))
13916
+ return value.map((item) => redactValue(item, replacement));
13917
+ if (!value || typeof value !== "object")
13918
+ return value;
13919
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
13920
+ key,
13921
+ shouldRedactKey(key) ? replacement : redactValue(item, replacement)
13922
+ ]));
13923
+ }
13924
+ function setPath(input, path, replacement) {
13925
+ const parts = path.split(".");
13926
+ let cursor = input;
13927
+ for (const part of parts.slice(0, -1)) {
13928
+ const next = cursor[part];
13929
+ if (!next || typeof next !== "object")
13930
+ return;
13931
+ cursor = next;
13932
+ }
13933
+ const last = parts.at(-1);
13934
+ if (last && last in cursor)
13935
+ cursor[last] = replacement;
13936
+ }
13937
+ function normalizeTime(value) {
13938
+ if (!value)
13939
+ return new Date().toISOString();
13940
+ return value instanceof Date ? value.toISOString() : value;
13941
+ }
13942
+ function normalizeRetryPolicy(policy) {
13943
+ return {
13944
+ maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
13945
+ backoffMs: Math.max(0, policy?.backoffMs ?? 250),
13946
+ multiplier: Math.max(1, policy?.multiplier ?? 2)
13947
+ };
13948
+ }
13949
+
13430
13950
  // src/commands/status.ts
13431
13951
  function getStatus() {
13432
13952
  const manifest = readManifest();
@@ -13476,13 +13996,16 @@ function getServeInfo(options = {}) {
13476
13996
  "/api/status",
13477
13997
  "/api/manifest",
13478
13998
  "/api/notifications",
13999
+ "/api/webhooks",
14000
+ "/api/events",
13479
14001
  "/api/doctor",
13480
14002
  "/api/self-test",
13481
14003
  "/api/apps/status",
13482
14004
  "/api/apps/diff",
13483
14005
  "/api/install-claude/status",
13484
14006
  "/api/install-claude/diff",
13485
- "/api/notifications/test"
14007
+ "/api/notifications/test",
14008
+ "/api/webhooks/test"
13486
14009
  ]
13487
14010
  };
13488
14011
  }
@@ -13649,6 +14172,7 @@ function jsonError(message, status = 400) {
13649
14172
  }
13650
14173
  function startDashboardServer(options = {}) {
13651
14174
  const info = getServeInfo(options);
14175
+ const events = new EventsClient;
13652
14176
  return Bun.serve({
13653
14177
  hostname: info.host,
13654
14178
  port: info.port,
@@ -13668,6 +14192,42 @@ function startDashboardServer(options = {}) {
13668
14192
  if (url.pathname === "/api/notifications") {
13669
14193
  return Response.json(listNotificationChannels());
13670
14194
  }
14195
+ if (url.pathname === "/api/webhooks") {
14196
+ if (request.method !== "GET") {
14197
+ return jsonError("Use GET for webhook channel listing.", 405);
14198
+ }
14199
+ return Response.json(sanitizeChannelsForOutput(await events.listChannels()));
14200
+ }
14201
+ if (url.pathname === "/api/events") {
14202
+ if (request.method === "GET") {
14203
+ return Response.json(await events.listEvents());
14204
+ }
14205
+ if (request.method !== "POST") {
14206
+ return jsonError("Use GET or POST for events.", 405);
14207
+ }
14208
+ const body = await parseJsonBody(request);
14209
+ const type = typeof body["type"] === "string" ? body["type"] : undefined;
14210
+ if (!type) {
14211
+ return jsonError("type is required.");
14212
+ }
14213
+ const source = typeof body["source"] === "string" ? body["source"] : "machines";
14214
+ const subject = typeof body["subject"] === "string" ? body["subject"] : undefined;
14215
+ const severity = typeof body["severity"] === "string" ? body["severity"] : undefined;
14216
+ const message = typeof body["message"] === "string" ? body["message"] : undefined;
14217
+ const dedupeKey = typeof body["dedupeKey"] === "string" ? body["dedupeKey"] : undefined;
14218
+ const data = body["data"] && typeof body["data"] === "object" && !Array.isArray(body["data"]) ? body["data"] : {};
14219
+ const metadata = body["metadata"] && typeof body["metadata"] === "object" && !Array.isArray(body["metadata"]) ? body["metadata"] : {};
14220
+ return Response.json(await events.emit({
14221
+ source,
14222
+ type,
14223
+ subject,
14224
+ severity,
14225
+ message,
14226
+ dedupeKey,
14227
+ data,
14228
+ metadata
14229
+ }));
14230
+ }
13671
14231
  if (url.pathname === "/api/doctor") {
13672
14232
  return Response.json(runDoctor(machineId));
13673
14233
  }
@@ -13705,6 +14265,28 @@ function startDashboardServer(options = {}) {
13705
14265
  return jsonError(error instanceof Error ? error.message : String(error));
13706
14266
  }
13707
14267
  }
14268
+ if (url.pathname === "/api/webhooks/test") {
14269
+ if (request.method !== "POST") {
14270
+ return jsonError("Use POST for webhook tests.", 405);
14271
+ }
14272
+ const body = await parseJsonBody(request);
14273
+ const channelId = typeof body["channelId"] === "string" ? body["channelId"] : undefined;
14274
+ if (!channelId) {
14275
+ return jsonError("channelId is required.");
14276
+ }
14277
+ const type = typeof body["type"] === "string" ? body["type"] : "events.test";
14278
+ const message = typeof body["message"] === "string" ? body["message"] : undefined;
14279
+ try {
14280
+ return Response.json(await events.testChannel(channelId, {
14281
+ source: "machines",
14282
+ type,
14283
+ subject: channelId,
14284
+ message
14285
+ }));
14286
+ } catch (error) {
14287
+ return jsonError(error instanceof Error ? error.message : String(error));
14288
+ }
14289
+ }
13708
14290
  return new Response(renderDashboardHtml(), {
13709
14291
  headers: {
13710
14292
  "content-type": "text/html; charset=utf-8"
@@ -13744,7 +14326,7 @@ function runSelfTest() {
13744
14326
  };
13745
14327
  }
13746
14328
  // src/commands/setup.ts
13747
- import { homedir as homedir4 } from "os";
14329
+ import { homedir as homedir5 } from "os";
13748
14330
  function quote3(value) {
13749
14331
  return `'${value.replace(/'/g, `'\\''`)}'`;
13750
14332
  }
@@ -13814,7 +14396,7 @@ function buildSetupPlan(machineId) {
13814
14396
  const target = selected || {
13815
14397
  id: currentMachineId,
13816
14398
  platform: "linux",
13817
- workspacePath: `${homedir4()}/workspace`
14399
+ workspacePath: `${homedir5()}/workspace`
13818
14400
  };
13819
14401
  const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
13820
14402
  return {
@@ -13980,8 +14562,8 @@ function buildScreenEnableCommand(machineId, options = {}) {
13980
14562
  };
13981
14563
  }
13982
14564
  // src/commands/sync.ts
13983
- import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
13984
- import { homedir as homedir5 } from "os";
14565
+ import { existsSync as existsSync8, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
14566
+ import { homedir as homedir6 } from "os";
13985
14567
  function quote4(value) {
13986
14568
  return `'${value.replace(/'/g, `'\\''`)}'`;
13987
14569
  }
@@ -14029,8 +14611,8 @@ function detectPackageActions(machine) {
14029
14611
  }
14030
14612
  function detectFileActions(machine) {
14031
14613
  return (machine.files || []).map((file, index) => {
14032
- const sourceExists = existsSync7(file.source);
14033
- const targetExists = existsSync7(file.target);
14614
+ const sourceExists = existsSync8(file.source);
14615
+ const targetExists = existsSync8(file.target);
14034
14616
  let status = "missing";
14035
14617
  if (sourceExists && targetExists) {
14036
14618
  if (file.mode === "symlink") {
@@ -14058,7 +14640,7 @@ function buildSyncPlan(machineId) {
14058
14640
  const target = selected || {
14059
14641
  id: currentMachineId,
14060
14642
  platform: "linux",
14061
- workspacePath: `${homedir5()}/workspace`
14643
+ workspacePath: `${homedir6()}/workspace`
14062
14644
  };
14063
14645
  const actions = [
14064
14646
  ...detectPackageActions(target),
@@ -23232,6 +23814,13 @@ var MACHINE_MCP_TOOL_NAMES = [
23232
23814
  "machines_notifications_test",
23233
23815
  "machines_notifications_dispatch",
23234
23816
  "machines_notifications_remove",
23817
+ "machines_webhooks_add",
23818
+ "machines_webhooks_list",
23819
+ "machines_webhooks_test",
23820
+ "machines_webhooks_remove",
23821
+ "machines_events_emit",
23822
+ "machines_events_list",
23823
+ "machines_events_replay",
23235
23824
  "machines_serve_info",
23236
23825
  "machines_serve_dashboard",
23237
23826
  "storage_status",
@@ -23244,6 +23833,7 @@ function buildServer(version2 = getPackageVersion()) {
23244
23833
  }
23245
23834
  function createMcpServer(version2) {
23246
23835
  const server = new McpServer({ name: "machines", version: version2 });
23836
+ const events = new EventsClient;
23247
23837
  server.tool("machines_status", "Return local machine fleet status paths and machine identity.", {}, async () => ({
23248
23838
  content: [{ type: "text", text: JSON.stringify(getStatus(), null, 2) }]
23249
23839
  }));
@@ -23386,8 +23976,8 @@ function createMcpServer(version2) {
23386
23976
  target: exports_external.string().describe("Email, webhook URL, or shell command"),
23387
23977
  events: exports_external.array(exports_external.string()).describe("Events routed to this channel"),
23388
23978
  enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
23389
- }, async ({ channel_id, type, target, events, enabled }) => ({
23390
- content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, events, enabled: enabled ?? true }), null, 2) }]
23979
+ }, async ({ channel_id, type, target, events: events2, enabled }) => ({
23980
+ content: [{ type: "text", text: JSON.stringify(addNotificationChannel({ id: channel_id, type, target, events: events2, enabled: enabled ?? true }), null, 2) }]
23391
23981
  }));
23392
23982
  server.tool("machines_notifications_list", "List notification channels.", {}, async () => ({
23393
23983
  content: [{ type: "text", text: JSON.stringify(listNotificationChannels(), null, 2) }]
@@ -23397,6 +23987,60 @@ function createMcpServer(version2) {
23397
23987
  }));
23398
23988
  server.tool("machines_notifications_dispatch", "Dispatch an event to matching notification channels.", { event: exports_external.string().describe("Event name"), message: exports_external.string().describe("Message body"), channel_id: exports_external.string().optional().describe("Limit delivery to one channel") }, async ({ event, message, channel_id }) => ({ content: [{ type: "text", text: JSON.stringify(await dispatchNotificationEvent(event, message, { channelId: channel_id }), null, 2) }] }));
23399
23989
  server.tool("machines_notifications_remove", "Remove a notification channel.", { channel_id: exports_external.string().describe("Channel identifier") }, async ({ channel_id }) => ({ content: [{ type: "text", text: JSON.stringify(removeNotificationChannel(channel_id), null, 2) }] }));
23990
+ server.tool("machines_webhooks_add", "Add or replace a shared Hasna event webhook channel.", {
23991
+ channel_id: exports_external.string().describe("Channel identifier"),
23992
+ url: exports_external.string().url().describe("Webhook URL"),
23993
+ event_type: exports_external.string().optional().describe("Optional event type filter, e.g. machines.*"),
23994
+ source: exports_external.string().optional().describe("Optional source filter"),
23995
+ secret: exports_external.string().optional().describe("Optional HMAC secret"),
23996
+ enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
23997
+ }, async ({ channel_id, url, event_type, source, secret, enabled }) => {
23998
+ const now2 = new Date().toISOString();
23999
+ const channel = await events.addChannel({
24000
+ id: channel_id,
24001
+ enabled: enabled ?? true,
24002
+ transport: "webhook",
24003
+ filters: event_type || source ? [{ type: event_type, source }] : undefined,
24004
+ webhook: { url, secret },
24005
+ createdAt: now2,
24006
+ updatedAt: now2
24007
+ });
24008
+ return { content: [{ type: "text", text: JSON.stringify(sanitizeChannelForOutput(channel), null, 2) }] };
24009
+ });
24010
+ server.tool("machines_webhooks_list", "List shared Hasna event webhook channels.", {}, async () => ({
24011
+ content: [{ type: "text", text: JSON.stringify(sanitizeChannelsForOutput(await events.listChannels()), null, 2) }]
24012
+ }));
24013
+ server.tool("machines_webhooks_test", "Send a test event to one shared Hasna event channel.", { channel_id: exports_external.string().describe("Channel identifier"), event_type: exports_external.string().optional().describe("Event type"), message: exports_external.string().optional().describe("Message body") }, async ({ channel_id, event_type, message }) => ({
24014
+ content: [{ type: "text", text: JSON.stringify(await events.testChannel(channel_id, { source: "machines", type: event_type ?? "events.test", message }), null, 2) }]
24015
+ }));
24016
+ server.tool("machines_webhooks_remove", "Remove a shared Hasna event channel.", { channel_id: exports_external.string().describe("Channel identifier") }, async ({ channel_id }) => ({ content: [{ type: "text", text: JSON.stringify({ removed: await events.removeChannel(channel_id) }, null, 2) }] }));
24017
+ server.tool("machines_events_emit", "Emit a shared Hasna event from machines.", {
24018
+ event_type: exports_external.string().describe("Event type"),
24019
+ subject: exports_external.string().optional().describe("Event subject"),
24020
+ severity: exports_external.enum(["debug", "info", "notice", "warning", "error", "critical"]).optional().describe("Event severity"),
24021
+ message: exports_external.string().optional().describe("Message body"),
24022
+ data: exports_external.record(exports_external.unknown()).optional().describe("Event data"),
24023
+ metadata: exports_external.record(exports_external.unknown()).optional().describe("Event metadata"),
24024
+ dedupe_key: exports_external.string().optional().describe("Dedupe key"),
24025
+ deliver: exports_external.boolean().optional().describe("Deliver to matching channels")
24026
+ }, async ({ event_type, subject, severity, message, data, metadata, dedupe_key, deliver }) => ({
24027
+ content: [{ type: "text", text: JSON.stringify(await events.emit({
24028
+ source: "machines",
24029
+ type: event_type,
24030
+ subject,
24031
+ severity,
24032
+ message,
24033
+ data: data ?? {},
24034
+ metadata: metadata ?? {},
24035
+ dedupeKey: dedupe_key
24036
+ }, { deliver: deliver !== false }), null, 2) }]
24037
+ }));
24038
+ server.tool("machines_events_list", "List shared Hasna events.", {}, async () => ({
24039
+ content: [{ type: "text", text: JSON.stringify(await events.listEvents(), null, 2) }]
24040
+ }));
24041
+ server.tool("machines_events_replay", "Replay shared Hasna events.", { event_id: exports_external.string().optional().describe("Event id"), source: exports_external.string().optional().describe("Source filter"), event_type: exports_external.string().optional().describe("Event type filter"), dry_run: exports_external.boolean().optional().describe("Preview without delivery") }, async ({ event_id, source, event_type, dry_run }) => ({
24042
+ content: [{ type: "text", text: JSON.stringify(await events.replay({ eventId: event_id, source, type: event_type, dryRun: dry_run }), null, 2) }]
24043
+ }));
23400
24044
  server.tool("machines_serve_info", "Preview the dashboard server bind address and routes.", { host: exports_external.string().optional().describe("Host interface"), port: exports_external.number().optional().describe("Port number") }, async ({ host, port }) => ({ content: [{ type: "text", text: JSON.stringify(getServeInfo({ host, port }), null, 2) }] }));
23401
24045
  server.tool("machines_serve_dashboard", "Render the current dashboard HTML.", {}, async () => ({
23402
24046
  content: [{ type: "text", text: renderDashboardHtml() }]