@hasna/machines 0.0.26 → 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 {
@@ -13859,15 +14441,32 @@ function runSetup(machineId, options = {}) {
13859
14441
  return summary;
13860
14442
  }
13861
14443
  // src/commands/screen.ts
14444
+ var DEFAULT_SCREEN_SECRET_NAMESPACE = "hasna/xyz/opensource/machines/prod";
13862
14445
  function shellQuote7(value) {
13863
14446
  return `'${value.replace(/'/g, "'\\''")}'`;
13864
14447
  }
14448
+ function shellCommand2(command) {
14449
+ return command.map(shellQuote7).join(" ");
14450
+ }
14451
+ function metadataString2(metadata, keys) {
14452
+ if (!metadata)
14453
+ return null;
14454
+ for (const key of keys) {
14455
+ const value = metadata[key];
14456
+ if (typeof value === "string" && value.trim())
14457
+ return value.trim();
14458
+ }
14459
+ return null;
14460
+ }
13865
14461
  function splitTarget(target) {
13866
14462
  const at = target.indexOf("@");
13867
14463
  if (at === -1)
13868
14464
  return [null, target];
13869
14465
  return [target.slice(0, at), target.slice(at + 1)];
13870
14466
  }
14467
+ function defaultScreenPasswordSecretKey(machineId) {
14468
+ return `${DEFAULT_SCREEN_SECRET_NAMESPACE}/screen-${machineId}-vnc-password`;
14469
+ }
13871
14470
  function resolveScreenTarget(machineId, options = {}) {
13872
14471
  const resolved = resolveMachineRoute(machineId, options);
13873
14472
  if (!resolved.ok || !resolved.target) {
@@ -13893,6 +14492,30 @@ function resolveScreenTarget(machineId, options = {}) {
13893
14492
  warnings: resolved.warnings
13894
14493
  };
13895
14494
  }
14495
+ function resolveScreenCredentials(machineId, options = {}) {
14496
+ const topology = options.topology ?? discoverMachineTopology(options);
14497
+ const screen = resolveScreenTarget(machineId, { ...options, topology });
14498
+ const entry = topology.machines.find((machine) => machine.machine_id === screen.machineId);
14499
+ const metadata = entry?.metadata;
14500
+ const metadataUser = metadataString2(metadata, ["screenUser", "screen_user", "user", "username"]);
14501
+ const metadataPasswordSecret = metadataString2(metadata, [
14502
+ "screenPasswordSecret",
14503
+ "screen_password_secret",
14504
+ "screenVncPasswordSecret",
14505
+ "screen_vnc_password_secret",
14506
+ "vncPasswordSecret",
14507
+ "vnc_password_secret"
14508
+ ]);
14509
+ const user = options.user ?? screen.user ?? metadataUser;
14510
+ const passwordSecretKey = options.passwordSecretKey ?? metadataPasswordSecret ?? defaultScreenPasswordSecretKey(screen.machineId);
14511
+ return {
14512
+ machineId: screen.machineId,
14513
+ user: user ?? null,
14514
+ userSource: options.user ? "option" : screen.user ? "route" : metadataUser ? "metadata" : "missing",
14515
+ passwordSecretKey,
14516
+ passwordSecretSource: options.passwordSecretKey ? "option" : metadataPasswordSecret ? "metadata" : "default"
14517
+ };
14518
+ }
13896
14519
  function buildScreenCommand(machineId, options = {}) {
13897
14520
  const resolved = resolveScreenTarget(machineId, options);
13898
14521
  return `open ${resolved.url}`;
@@ -13907,9 +14530,40 @@ function buildScreenEnableRemoteCommand(user, vncPassword) {
13907
14530
  ];
13908
14531
  return lines.join(" && ");
13909
14532
  }
14533
+ function buildScreenEnableRemoteCommandFromStdin(user) {
14534
+ const kickstart = "/System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart";
14535
+ const script = [
14536
+ "set -euo pipefail",
14537
+ 'user="$1"',
14538
+ "IFS= read -r vnc_pw",
14539
+ 'if [ -z "$vnc_pw" ]; then echo "missing VNC password on stdin" >&2; exit 1; fi',
14540
+ `kickstart=${shellQuote7(kickstart)}`,
14541
+ 'dseditgroup -o edit -a "$user" -t user com.apple.access_screensharing 2>/dev/null || true',
14542
+ "defaults write /Library/Preferences/com.apple.RemoteManagement AllowSRPForNetworkNodes -bool true",
14543
+ '"$kickstart" -configure -clientopts -setvnclegacy -vnclegacy yes -setvncpw -vncpw "$vnc_pw"',
14544
+ '"$kickstart" -activate -configure -access -on -users "$user" -privs -all -restart -agent -menu'
14545
+ ].join(`
14546
+ `);
14547
+ return `sudo -n -p '' /bin/bash -c ${shellQuote7(script)} -- ${shellQuote7(user)}`;
14548
+ }
14549
+ function buildScreenEnableCommand(machineId, options = {}) {
14550
+ const credentials = resolveScreenCredentials(machineId, options);
14551
+ if (!credentials.user) {
14552
+ throw new Error(`No screen-sharing user known for ${machineId}; pass --user <name> or set metadata.user in the manifest.`);
14553
+ }
14554
+ const secretsCommand = options.secretsCommand || "secrets";
14555
+ const remoteCommand = buildScreenEnableRemoteCommandFromStdin(credentials.user);
14556
+ return {
14557
+ machineId: credentials.machineId,
14558
+ user: credentials.user,
14559
+ passwordSecretKey: credentials.passwordSecretKey,
14560
+ remoteCommand,
14561
+ command: `${shellCommand2([secretsCommand, "get", credentials.passwordSecretKey])} | ${buildSshCommand(machineId, remoteCommand, options)}`
14562
+ };
14563
+ }
13910
14564
  // src/commands/sync.ts
13911
- import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
13912
- 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";
13913
14567
  function quote4(value) {
13914
14568
  return `'${value.replace(/'/g, `'\\''`)}'`;
13915
14569
  }
@@ -13957,8 +14611,8 @@ function detectPackageActions(machine) {
13957
14611
  }
13958
14612
  function detectFileActions(machine) {
13959
14613
  return (machine.files || []).map((file, index) => {
13960
- const sourceExists = existsSync7(file.source);
13961
- const targetExists = existsSync7(file.target);
14614
+ const sourceExists = existsSync8(file.source);
14615
+ const targetExists = existsSync8(file.target);
13962
14616
  let status = "missing";
13963
14617
  if (sourceExists && targetExists) {
13964
14618
  if (file.mode === "symlink") {
@@ -13986,7 +14640,7 @@ function buildSyncPlan(machineId) {
13986
14640
  const target = selected || {
13987
14641
  id: currentMachineId,
13988
14642
  platform: "linux",
13989
- workspacePath: `${homedir5()}/workspace`
14643
+ workspacePath: `${homedir6()}/workspace`
13990
14644
  };
13991
14645
  const actions = [
13992
14646
  ...detectPackageActions(target),
@@ -23160,6 +23814,13 @@ var MACHINE_MCP_TOOL_NAMES = [
23160
23814
  "machines_notifications_test",
23161
23815
  "machines_notifications_dispatch",
23162
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",
23163
23824
  "machines_serve_info",
23164
23825
  "machines_serve_dashboard",
23165
23826
  "storage_status",
@@ -23172,6 +23833,7 @@ function buildServer(version2 = getPackageVersion()) {
23172
23833
  }
23173
23834
  function createMcpServer(version2) {
23174
23835
  const server = new McpServer({ name: "machines", version: version2 });
23836
+ const events = new EventsClient;
23175
23837
  server.tool("machines_status", "Return local machine fleet status paths and machine identity.", {}, async () => ({
23176
23838
  content: [{ type: "text", text: JSON.stringify(getStatus(), null, 2) }]
23177
23839
  }));
@@ -23314,8 +23976,8 @@ function createMcpServer(version2) {
23314
23976
  target: exports_external.string().describe("Email, webhook URL, or shell command"),
23315
23977
  events: exports_external.array(exports_external.string()).describe("Events routed to this channel"),
23316
23978
  enabled: exports_external.boolean().optional().describe("Whether the channel is enabled")
23317
- }, async ({ channel_id, type, target, events, enabled }) => ({
23318
- 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) }]
23319
23981
  }));
23320
23982
  server.tool("machines_notifications_list", "List notification channels.", {}, async () => ({
23321
23983
  content: [{ type: "text", text: JSON.stringify(listNotificationChannels(), null, 2) }]
@@ -23325,6 +23987,60 @@ function createMcpServer(version2) {
23325
23987
  }));
23326
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) }] }));
23327
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
+ }));
23328
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) }] }));
23329
24045
  server.tool("machines_serve_dashboard", "Render the current dashboard HTML.", {}, async () => ({
23330
24046
  content: [{ type: "text", text: renderDashboardHtml() }]
@@ -23362,6 +24078,7 @@ export {
23362
24078
  resolveTables,
23363
24079
  resolveSshTarget,
23364
24080
  resolveScreenTarget,
24081
+ resolveScreenCredentials,
23365
24082
  resolveMachineWorkspace,
23366
24083
  resolveMachineRoute,
23367
24084
  resolveBackupTarget,
@@ -23425,6 +24142,7 @@ export {
23425
24142
  diffClaudeCli,
23426
24143
  diffApps,
23427
24144
  detectCurrentMachineManifest,
24145
+ defaultScreenPasswordSecretKey,
23428
24146
  createMcpServer,
23429
24147
  createMachineResolverSnapshot,
23430
24148
  countRuns,
@@ -23435,7 +24153,9 @@ export {
23435
24153
  buildSshCommand,
23436
24154
  buildSetupPlan,
23437
24155
  buildServer,
24156
+ buildScreenEnableRemoteCommandFromStdin,
23438
24157
  buildScreenEnableRemoteCommand,
24158
+ buildScreenEnableCommand,
23439
24159
  buildScreenCommand,
23440
24160
  buildClaudeInstallPlan,
23441
24161
  buildCertPlan,
@@ -23465,6 +24185,7 @@ export {
23465
24185
  MACHINES_BACKUP_PREFIX_ENV,
23466
24186
  MACHINES_BACKUP_BUCKET_FALLBACK_ENV,
23467
24187
  MACHINES_BACKUP_BUCKET_ENV,
24188
+ DEFAULT_SCREEN_SECRET_NAMESPACE,
23468
24189
  DEFAULT_MACHINE_RESOLVER_TTL_MS,
23469
24190
  DEFAULT_BACKUP_PREFIX,
23470
24191
  CROSSREFS_KEY