@hasna/machines 0.0.27 → 0.0.29
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/README.md +16 -0
- package/dist/cli/index.js +1485 -113
- package/dist/commands/runtime.d.ts +32 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +745 -10
- package/dist/mcp/index.js +650 -72
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/package.json +2 -1
package/dist/cli/index.js
CHANGED
|
@@ -2082,34 +2082,34 @@ var require_commander = __commonJS((exports) => {
|
|
|
2082
2082
|
});
|
|
2083
2083
|
|
|
2084
2084
|
// src/paths.ts
|
|
2085
|
-
import { existsSync as
|
|
2086
|
-
import { dirname as dirname2, join as
|
|
2085
|
+
import { existsSync as existsSync3, mkdirSync } from "fs";
|
|
2086
|
+
import { dirname as dirname2, join as join3, resolve } from "path";
|
|
2087
2087
|
function homeDir() {
|
|
2088
2088
|
return process.env["HOME"] || process.env["USERPROFILE"] || "~";
|
|
2089
2089
|
}
|
|
2090
2090
|
function getDataDir() {
|
|
2091
|
-
return process.env["HASNA_MACHINES_DIR"] ||
|
|
2091
|
+
return process.env["HASNA_MACHINES_DIR"] || join3(homeDir(), ".hasna", "machines");
|
|
2092
2092
|
}
|
|
2093
2093
|
function getDbPath() {
|
|
2094
|
-
return process.env["HASNA_MACHINES_DB_PATH"] ||
|
|
2094
|
+
return process.env["HASNA_MACHINES_DB_PATH"] || join3(getDataDir(), "machines.db");
|
|
2095
2095
|
}
|
|
2096
2096
|
function getManifestPath() {
|
|
2097
|
-
return process.env["HASNA_MACHINES_MANIFEST_PATH"] ||
|
|
2097
|
+
return process.env["HASNA_MACHINES_MANIFEST_PATH"] || join3(getDataDir(), "machines.json");
|
|
2098
2098
|
}
|
|
2099
2099
|
function getNotificationsPath() {
|
|
2100
|
-
return process.env["HASNA_MACHINES_NOTIFICATIONS_PATH"] ||
|
|
2100
|
+
return process.env["HASNA_MACHINES_NOTIFICATIONS_PATH"] || join3(getDataDir(), "notifications.json");
|
|
2101
2101
|
}
|
|
2102
2102
|
function getClipboardKeyPath() {
|
|
2103
|
-
return process.env["HASNA_MACHINES_CLIPBOARD_KEY_PATH"] ||
|
|
2103
|
+
return process.env["HASNA_MACHINES_CLIPBOARD_KEY_PATH"] || join3(getDataDir(), "clipboard.key");
|
|
2104
2104
|
}
|
|
2105
2105
|
function getClipboardHistoryPath() {
|
|
2106
|
-
return process.env["HASNA_MACHINES_CLIPBOARD_HISTORY_PATH"] ||
|
|
2106
|
+
return process.env["HASNA_MACHINES_CLIPBOARD_HISTORY_PATH"] || join3(getDataDir(), "clipboard-history.json");
|
|
2107
2107
|
}
|
|
2108
2108
|
function ensureParentDir(filePath) {
|
|
2109
2109
|
if (filePath === ":memory:")
|
|
2110
2110
|
return;
|
|
2111
2111
|
const dir = dirname2(resolve(filePath));
|
|
2112
|
-
if (!
|
|
2112
|
+
if (!existsSync3(dir)) {
|
|
2113
2113
|
mkdirSync(dir, { recursive: true });
|
|
2114
2114
|
}
|
|
2115
2115
|
}
|
|
@@ -2203,15 +2203,15 @@ function countRuns(table) {
|
|
|
2203
2203
|
}
|
|
2204
2204
|
function recordSetupRun(machineId, status, details) {
|
|
2205
2205
|
const db = getDb();
|
|
2206
|
-
const
|
|
2206
|
+
const now2 = new Date().toISOString();
|
|
2207
2207
|
db.query(`INSERT INTO setup_runs (id, machine_id, status, details_json, created_at, updated_at)
|
|
2208
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(details),
|
|
2208
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(details), now2, now2);
|
|
2209
2209
|
}
|
|
2210
2210
|
function recordSyncRun(machineId, status, actions) {
|
|
2211
2211
|
const db = getDb();
|
|
2212
|
-
const
|
|
2212
|
+
const now2 = new Date().toISOString();
|
|
2213
2213
|
db.query(`INSERT INTO sync_runs (id, machine_id, status, actions_json, created_at, updated_at)
|
|
2214
|
-
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(actions),
|
|
2214
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(crypto.randomUUID(), machineId, status, JSON.stringify(actions), now2, now2);
|
|
2215
2215
|
}
|
|
2216
2216
|
var adapter = null;
|
|
2217
2217
|
var init_db = __esm(() => {
|
|
@@ -2479,7 +2479,7 @@ function upsertSqlite(db, table, columns, rows) {
|
|
|
2479
2479
|
}
|
|
2480
2480
|
function recordSyncMeta(db, direction, results) {
|
|
2481
2481
|
ensureSyncMetaTable(db);
|
|
2482
|
-
const
|
|
2482
|
+
const now3 = new Date().toISOString();
|
|
2483
2483
|
const statement = db.query(`
|
|
2484
2484
|
INSERT INTO _machines_sync_meta (table_name, last_synced_at, direction)
|
|
2485
2485
|
VALUES (?, ?, ?)
|
|
@@ -2488,7 +2488,7 @@ function recordSyncMeta(db, direction, results) {
|
|
|
2488
2488
|
for (const result of results) {
|
|
2489
2489
|
if (result.errors.length > 0)
|
|
2490
2490
|
continue;
|
|
2491
|
-
statement.run(result.table,
|
|
2491
|
+
statement.run(result.table, now3, direction);
|
|
2492
2492
|
}
|
|
2493
2493
|
}
|
|
2494
2494
|
function ensureSyncMetaTable(db) {
|
|
@@ -2601,6 +2601,684 @@ var {
|
|
|
2601
2601
|
Help
|
|
2602
2602
|
} = import__.default;
|
|
2603
2603
|
|
|
2604
|
+
// node_modules/@hasna/events/dist/commander.js
|
|
2605
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
2606
|
+
import { existsSync } from "fs";
|
|
2607
|
+
import { homedir } from "os";
|
|
2608
|
+
import { join } from "path";
|
|
2609
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
2610
|
+
import { randomUUID } from "crypto";
|
|
2611
|
+
import { spawn } from "child_process";
|
|
2612
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
2613
|
+
function getPathValue(input, path) {
|
|
2614
|
+
return path.split(".").reduce((value, part) => {
|
|
2615
|
+
if (value && typeof value === "object" && part in value) {
|
|
2616
|
+
return value[part];
|
|
2617
|
+
}
|
|
2618
|
+
return;
|
|
2619
|
+
}, input);
|
|
2620
|
+
}
|
|
2621
|
+
function wildcardToRegExp(pattern) {
|
|
2622
|
+
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
|
|
2623
|
+
return new RegExp(`^${escaped}$`);
|
|
2624
|
+
}
|
|
2625
|
+
function matchString(value, matcher) {
|
|
2626
|
+
if (matcher === undefined)
|
|
2627
|
+
return true;
|
|
2628
|
+
if (value === undefined)
|
|
2629
|
+
return false;
|
|
2630
|
+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
2631
|
+
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
2632
|
+
}
|
|
2633
|
+
function matchRecord(input, matcher) {
|
|
2634
|
+
if (!matcher)
|
|
2635
|
+
return true;
|
|
2636
|
+
return Object.entries(matcher).every(([path, expected]) => {
|
|
2637
|
+
const actual = getPathValue(input, path);
|
|
2638
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
2639
|
+
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
2640
|
+
}
|
|
2641
|
+
return actual === expected;
|
|
2642
|
+
});
|
|
2643
|
+
}
|
|
2644
|
+
function eventMatchesFilter(event, filter) {
|
|
2645
|
+
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);
|
|
2646
|
+
}
|
|
2647
|
+
function channelMatchesEvent(channel, event) {
|
|
2648
|
+
if (!channel.enabled)
|
|
2649
|
+
return false;
|
|
2650
|
+
if (!channel.filters || channel.filters.length === 0)
|
|
2651
|
+
return true;
|
|
2652
|
+
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
2653
|
+
}
|
|
2654
|
+
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
2655
|
+
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
2656
|
+
function getEventsDataDir(override) {
|
|
2657
|
+
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join(homedir(), ".hasna", "events");
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
class JsonEventsStore {
|
|
2661
|
+
dataDir;
|
|
2662
|
+
channelsPath;
|
|
2663
|
+
eventsPath;
|
|
2664
|
+
deliveriesPath;
|
|
2665
|
+
constructor(dataDir = getEventsDataDir()) {
|
|
2666
|
+
this.dataDir = dataDir;
|
|
2667
|
+
this.channelsPath = join(dataDir, "channels.json");
|
|
2668
|
+
this.eventsPath = join(dataDir, "events.json");
|
|
2669
|
+
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
2670
|
+
}
|
|
2671
|
+
async init() {
|
|
2672
|
+
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
2673
|
+
await chmod(this.dataDir, 448).catch(() => {
|
|
2674
|
+
return;
|
|
2675
|
+
});
|
|
2676
|
+
await this.ensureArrayFile(this.channelsPath);
|
|
2677
|
+
await this.ensureArrayFile(this.eventsPath);
|
|
2678
|
+
await this.ensureArrayFile(this.deliveriesPath);
|
|
2679
|
+
}
|
|
2680
|
+
async addChannel(channel) {
|
|
2681
|
+
await this.init();
|
|
2682
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
2683
|
+
const index = channels.findIndex((item) => item.id === channel.id);
|
|
2684
|
+
if (index >= 0) {
|
|
2685
|
+
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
2686
|
+
} else {
|
|
2687
|
+
channels.push(channel);
|
|
2688
|
+
}
|
|
2689
|
+
await this.writeJson(this.channelsPath, channels);
|
|
2690
|
+
return index >= 0 ? channels[index] : channel;
|
|
2691
|
+
}
|
|
2692
|
+
async listChannels() {
|
|
2693
|
+
await this.init();
|
|
2694
|
+
return this.readJson(this.channelsPath, []);
|
|
2695
|
+
}
|
|
2696
|
+
async getChannel(id) {
|
|
2697
|
+
const channels = await this.listChannels();
|
|
2698
|
+
return channels.find((channel) => channel.id === id);
|
|
2699
|
+
}
|
|
2700
|
+
async removeChannel(id) {
|
|
2701
|
+
await this.init();
|
|
2702
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
2703
|
+
const next = channels.filter((channel) => channel.id !== id);
|
|
2704
|
+
await this.writeJson(this.channelsPath, next);
|
|
2705
|
+
return next.length !== channels.length;
|
|
2706
|
+
}
|
|
2707
|
+
async appendEvent(event) {
|
|
2708
|
+
await this.init();
|
|
2709
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
2710
|
+
events.push(event);
|
|
2711
|
+
await this.writeJson(this.eventsPath, events);
|
|
2712
|
+
return event;
|
|
2713
|
+
}
|
|
2714
|
+
async listEvents() {
|
|
2715
|
+
await this.init();
|
|
2716
|
+
return this.readJson(this.eventsPath, []);
|
|
2717
|
+
}
|
|
2718
|
+
async findEventByIdentity(identity) {
|
|
2719
|
+
const events = await this.listEvents();
|
|
2720
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
2721
|
+
}
|
|
2722
|
+
async appendDelivery(result) {
|
|
2723
|
+
await this.init();
|
|
2724
|
+
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
2725
|
+
deliveries.push(result);
|
|
2726
|
+
await this.writeJson(this.deliveriesPath, deliveries);
|
|
2727
|
+
return result;
|
|
2728
|
+
}
|
|
2729
|
+
async listDeliveries() {
|
|
2730
|
+
await this.init();
|
|
2731
|
+
return this.readJson(this.deliveriesPath, []);
|
|
2732
|
+
}
|
|
2733
|
+
async exportData() {
|
|
2734
|
+
return {
|
|
2735
|
+
channels: await this.listChannels(),
|
|
2736
|
+
events: await this.listEvents(),
|
|
2737
|
+
deliveries: await this.listDeliveries()
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
async ensureArrayFile(path) {
|
|
2741
|
+
if (!existsSync(path)) {
|
|
2742
|
+
await writeFile(path, `[]
|
|
2743
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
2744
|
+
}
|
|
2745
|
+
await chmod(path, 384).catch(() => {
|
|
2746
|
+
return;
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
async readJson(path, fallback) {
|
|
2750
|
+
try {
|
|
2751
|
+
const raw = await readFile(path, "utf-8");
|
|
2752
|
+
if (!raw.trim())
|
|
2753
|
+
return fallback;
|
|
2754
|
+
return JSON.parse(raw);
|
|
2755
|
+
} catch (error) {
|
|
2756
|
+
if (error.code === "ENOENT")
|
|
2757
|
+
return fallback;
|
|
2758
|
+
throw error;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
async writeJson(path, value) {
|
|
2762
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
2763
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
2764
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
2765
|
+
await rename(tempPath, path);
|
|
2766
|
+
await chmod(path, 384).catch(() => {
|
|
2767
|
+
return;
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
2772
|
+
function buildSignatureBase(timestamp, body) {
|
|
2773
|
+
return `${timestamp}.${body}`;
|
|
2774
|
+
}
|
|
2775
|
+
function signPayload(secret, timestamp, body) {
|
|
2776
|
+
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
|
|
2777
|
+
return `sha256=${digest}`;
|
|
2778
|
+
}
|
|
2779
|
+
function now() {
|
|
2780
|
+
return new Date().toISOString();
|
|
2781
|
+
}
|
|
2782
|
+
function truncate(value, max = 4096) {
|
|
2783
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
2784
|
+
}
|
|
2785
|
+
function buildWebhookRequest(event, channel) {
|
|
2786
|
+
if (!channel.webhook)
|
|
2787
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
2788
|
+
const body = JSON.stringify(event);
|
|
2789
|
+
const timestamp = event.time;
|
|
2790
|
+
const headers = {
|
|
2791
|
+
"Content-Type": "application/json",
|
|
2792
|
+
"User-Agent": "@hasna/events",
|
|
2793
|
+
"X-Hasna-Event-Id": event.id,
|
|
2794
|
+
"X-Hasna-Event-Type": event.type,
|
|
2795
|
+
"X-Hasna-Timestamp": timestamp,
|
|
2796
|
+
...channel.webhook.headers
|
|
2797
|
+
};
|
|
2798
|
+
if (channel.webhook.secret) {
|
|
2799
|
+
headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
|
|
2800
|
+
}
|
|
2801
|
+
return { body, headers };
|
|
2802
|
+
}
|
|
2803
|
+
async function dispatchWebhook(event, channel, options = {}) {
|
|
2804
|
+
if (!channel.webhook)
|
|
2805
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
2806
|
+
const startedAt = now();
|
|
2807
|
+
const { body, headers } = buildWebhookRequest(event, channel);
|
|
2808
|
+
const controller = new AbortController;
|
|
2809
|
+
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
2810
|
+
try {
|
|
2811
|
+
const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
|
|
2812
|
+
method: "POST",
|
|
2813
|
+
headers,
|
|
2814
|
+
body,
|
|
2815
|
+
signal: controller.signal
|
|
2816
|
+
});
|
|
2817
|
+
const responseBody = truncate(await response.text());
|
|
2818
|
+
return {
|
|
2819
|
+
attempt: 1,
|
|
2820
|
+
status: response.ok ? "success" : "failed",
|
|
2821
|
+
startedAt,
|
|
2822
|
+
completedAt: now(),
|
|
2823
|
+
responseStatus: response.status,
|
|
2824
|
+
responseBody,
|
|
2825
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
2826
|
+
};
|
|
2827
|
+
} catch (error) {
|
|
2828
|
+
return {
|
|
2829
|
+
attempt: 1,
|
|
2830
|
+
status: "failed",
|
|
2831
|
+
startedAt,
|
|
2832
|
+
completedAt: now(),
|
|
2833
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2834
|
+
};
|
|
2835
|
+
} finally {
|
|
2836
|
+
clearTimeout(timeout);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
async function dispatchCommand(event, channel) {
|
|
2840
|
+
if (!channel.command)
|
|
2841
|
+
throw new Error(`Channel ${channel.id} has no command config`);
|
|
2842
|
+
const startedAt = now();
|
|
2843
|
+
const eventJson = JSON.stringify(event);
|
|
2844
|
+
const env = {
|
|
2845
|
+
...process.env,
|
|
2846
|
+
...channel.command.env,
|
|
2847
|
+
HASNA_CHANNEL_ID: channel.id,
|
|
2848
|
+
HASNA_EVENT_ID: event.id,
|
|
2849
|
+
HASNA_EVENT_TYPE: event.type,
|
|
2850
|
+
HASNA_EVENT_SOURCE: event.source,
|
|
2851
|
+
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
2852
|
+
HASNA_EVENT_SEVERITY: event.severity,
|
|
2853
|
+
HASNA_EVENT_TIME: event.time,
|
|
2854
|
+
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
2855
|
+
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
2856
|
+
HASNA_EVENT_JSON: eventJson
|
|
2857
|
+
};
|
|
2858
|
+
return new Promise((resolve) => {
|
|
2859
|
+
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
2860
|
+
cwd: channel.command.cwd,
|
|
2861
|
+
env,
|
|
2862
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2863
|
+
});
|
|
2864
|
+
let stdout = "";
|
|
2865
|
+
let stderr = "";
|
|
2866
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
2867
|
+
child.stdin.end(eventJson);
|
|
2868
|
+
child.stdout.on("data", (chunk) => {
|
|
2869
|
+
stdout += chunk.toString();
|
|
2870
|
+
});
|
|
2871
|
+
child.stderr.on("data", (chunk) => {
|
|
2872
|
+
stderr += chunk.toString();
|
|
2873
|
+
});
|
|
2874
|
+
child.on("error", (error) => {
|
|
2875
|
+
clearTimeout(timeout);
|
|
2876
|
+
resolve({
|
|
2877
|
+
attempt: 1,
|
|
2878
|
+
status: "failed",
|
|
2879
|
+
startedAt,
|
|
2880
|
+
completedAt: now(),
|
|
2881
|
+
stdout: truncate(stdout),
|
|
2882
|
+
stderr: truncate(stderr),
|
|
2883
|
+
error: error.message
|
|
2884
|
+
});
|
|
2885
|
+
});
|
|
2886
|
+
child.on("close", (code, signal) => {
|
|
2887
|
+
clearTimeout(timeout);
|
|
2888
|
+
const success = code === 0;
|
|
2889
|
+
resolve({
|
|
2890
|
+
attempt: 1,
|
|
2891
|
+
status: success ? "success" : "failed",
|
|
2892
|
+
startedAt,
|
|
2893
|
+
completedAt: now(),
|
|
2894
|
+
stdout: truncate(stdout),
|
|
2895
|
+
stderr: truncate(stderr),
|
|
2896
|
+
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
2897
|
+
});
|
|
2898
|
+
});
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
async function dispatchChannel(event, channel, options = {}) {
|
|
2902
|
+
if (channel.transport === "webhook")
|
|
2903
|
+
return dispatchWebhook(event, channel, options);
|
|
2904
|
+
if (channel.transport === "command")
|
|
2905
|
+
return dispatchCommand(event, channel);
|
|
2906
|
+
return {
|
|
2907
|
+
attempt: 1,
|
|
2908
|
+
status: "skipped",
|
|
2909
|
+
startedAt: now(),
|
|
2910
|
+
completedAt: now(),
|
|
2911
|
+
error: `Unsupported transport: ${channel.transport}`
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
function createDeliveryResult(event, channel, attempts) {
|
|
2915
|
+
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
2916
|
+
return {
|
|
2917
|
+
id: randomUUID(),
|
|
2918
|
+
eventId: event.id,
|
|
2919
|
+
channelId: channel.id,
|
|
2920
|
+
transport: channel.transport,
|
|
2921
|
+
status,
|
|
2922
|
+
attempts,
|
|
2923
|
+
createdAt: attempts[0]?.startedAt ?? now(),
|
|
2924
|
+
completedAt: attempts.at(-1)?.completedAt ?? now()
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
function createEvent(input) {
|
|
2928
|
+
return {
|
|
2929
|
+
id: input.id ?? randomUUID2(),
|
|
2930
|
+
source: input.source,
|
|
2931
|
+
type: input.type,
|
|
2932
|
+
time: normalizeTime(input.time),
|
|
2933
|
+
subject: input.subject,
|
|
2934
|
+
severity: input.severity ?? "info",
|
|
2935
|
+
data: input.data ?? {},
|
|
2936
|
+
message: input.message,
|
|
2937
|
+
dedupeKey: input.dedupeKey,
|
|
2938
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
2939
|
+
metadata: input.metadata ?? {}
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
class EventsClient {
|
|
2944
|
+
store;
|
|
2945
|
+
redactors;
|
|
2946
|
+
transportOptions;
|
|
2947
|
+
constructor(options = {}) {
|
|
2948
|
+
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
2949
|
+
this.redactors = options.redactors ?? [];
|
|
2950
|
+
this.transportOptions = { fetchImpl: options.fetchImpl };
|
|
2951
|
+
}
|
|
2952
|
+
async addChannel(input) {
|
|
2953
|
+
const timestamp = new Date().toISOString();
|
|
2954
|
+
return this.store.addChannel({
|
|
2955
|
+
...input,
|
|
2956
|
+
createdAt: input.createdAt ?? timestamp,
|
|
2957
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
async listChannels() {
|
|
2961
|
+
return this.store.listChannels();
|
|
2962
|
+
}
|
|
2963
|
+
async removeChannel(id) {
|
|
2964
|
+
return this.store.removeChannel(id);
|
|
2965
|
+
}
|
|
2966
|
+
async emit(input, options = {}) {
|
|
2967
|
+
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
2968
|
+
if (options.dedupe !== false) {
|
|
2969
|
+
const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
|
|
2970
|
+
if (existing) {
|
|
2971
|
+
return { event: existing, deliveries: [], deduped: true };
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
await this.store.appendEvent(event);
|
|
2975
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
2976
|
+
return { event, deliveries, deduped: false };
|
|
2977
|
+
}
|
|
2978
|
+
async listEvents() {
|
|
2979
|
+
return this.store.listEvents();
|
|
2980
|
+
}
|
|
2981
|
+
async listDeliveries() {
|
|
2982
|
+
return this.store.listDeliveries();
|
|
2983
|
+
}
|
|
2984
|
+
async deliver(event) {
|
|
2985
|
+
const channels = await this.store.listChannels();
|
|
2986
|
+
const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
|
|
2987
|
+
const deliveries = [];
|
|
2988
|
+
for (const channel of selected) {
|
|
2989
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
2990
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
2991
|
+
await this.store.appendDelivery(result);
|
|
2992
|
+
deliveries.push(result);
|
|
2993
|
+
}
|
|
2994
|
+
return deliveries;
|
|
2995
|
+
}
|
|
2996
|
+
async testChannel(id, input = {}) {
|
|
2997
|
+
const channel = await this.store.getChannel(id);
|
|
2998
|
+
if (!channel)
|
|
2999
|
+
throw new Error(`Channel not found: ${id}`);
|
|
3000
|
+
const event = createEvent({
|
|
3001
|
+
source: input.source ?? "hasna.events",
|
|
3002
|
+
type: input.type ?? "events.test",
|
|
3003
|
+
subject: input.subject ?? id,
|
|
3004
|
+
severity: input.severity ?? "info",
|
|
3005
|
+
data: input.data ?? { test: true },
|
|
3006
|
+
message: input.message ?? "Hasna events test delivery",
|
|
3007
|
+
dedupeKey: input.dedupeKey,
|
|
3008
|
+
schemaVersion: input.schemaVersion,
|
|
3009
|
+
metadata: input.metadata,
|
|
3010
|
+
time: input.time,
|
|
3011
|
+
id: input.id
|
|
3012
|
+
});
|
|
3013
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
3014
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
3015
|
+
await this.store.appendDelivery(result);
|
|
3016
|
+
return result;
|
|
3017
|
+
}
|
|
3018
|
+
async replay(options = {}) {
|
|
3019
|
+
const events = (await this.store.listEvents()).filter((event) => {
|
|
3020
|
+
if (options.eventId && event.id !== options.eventId)
|
|
3021
|
+
return false;
|
|
3022
|
+
if (options.source && event.source !== options.source)
|
|
3023
|
+
return false;
|
|
3024
|
+
if (options.type && event.type !== options.type)
|
|
3025
|
+
return false;
|
|
3026
|
+
return true;
|
|
3027
|
+
});
|
|
3028
|
+
if (options.dryRun)
|
|
3029
|
+
return { events, deliveries: [] };
|
|
3030
|
+
const deliveries = [];
|
|
3031
|
+
for (const event of events) {
|
|
3032
|
+
deliveries.push(...await this.deliver(event));
|
|
3033
|
+
}
|
|
3034
|
+
return { events, deliveries };
|
|
3035
|
+
}
|
|
3036
|
+
async applyRedaction(event, channel) {
|
|
3037
|
+
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
3038
|
+
for (const redactor of this.redactors) {
|
|
3039
|
+
next = await redactor(next, channel);
|
|
3040
|
+
}
|
|
3041
|
+
return next;
|
|
3042
|
+
}
|
|
3043
|
+
async deliverWithRetry(event, channel) {
|
|
3044
|
+
const policy = normalizeRetryPolicy(channel.retry);
|
|
3045
|
+
const attempts = [];
|
|
3046
|
+
for (let index = 0;index < policy.maxAttempts; index += 1) {
|
|
3047
|
+
const attempt = await dispatchChannel(event, channel, this.transportOptions);
|
|
3048
|
+
attempt.attempt = index + 1;
|
|
3049
|
+
if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
|
|
3050
|
+
attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
|
|
3051
|
+
}
|
|
3052
|
+
attempts.push(attempt);
|
|
3053
|
+
if (attempt.status !== "failed")
|
|
3054
|
+
break;
|
|
3055
|
+
if (attempt.nextBackoffMs)
|
|
3056
|
+
await Bun.sleep(attempt.nextBackoffMs);
|
|
3057
|
+
}
|
|
3058
|
+
return createDeliveryResult(event, channel, attempts);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
3062
|
+
if (paths.length === 0)
|
|
3063
|
+
return event;
|
|
3064
|
+
const copy = structuredClone(event);
|
|
3065
|
+
for (const path of paths) {
|
|
3066
|
+
setPath(copy, path, replacement);
|
|
3067
|
+
}
|
|
3068
|
+
return copy;
|
|
3069
|
+
}
|
|
3070
|
+
function sanitizeChannelForOutput(channel) {
|
|
3071
|
+
const copy = structuredClone(channel);
|
|
3072
|
+
if (copy.webhook?.secret)
|
|
3073
|
+
copy.webhook.secret = "[REDACTED]";
|
|
3074
|
+
if (copy.command?.env) {
|
|
3075
|
+
copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
|
|
3076
|
+
}
|
|
3077
|
+
return copy;
|
|
3078
|
+
}
|
|
3079
|
+
function sanitizeChannelsForOutput(channels) {
|
|
3080
|
+
return channels.map(sanitizeChannelForOutput);
|
|
3081
|
+
}
|
|
3082
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
3083
|
+
return redactValue(event, replacement);
|
|
3084
|
+
}
|
|
3085
|
+
function shouldRedactKey(key) {
|
|
3086
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
3087
|
+
}
|
|
3088
|
+
function redactValue(value, replacement) {
|
|
3089
|
+
if (Array.isArray(value))
|
|
3090
|
+
return value.map((item) => redactValue(item, replacement));
|
|
3091
|
+
if (!value || typeof value !== "object")
|
|
3092
|
+
return value;
|
|
3093
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
3094
|
+
key,
|
|
3095
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
3096
|
+
]));
|
|
3097
|
+
}
|
|
3098
|
+
function setPath(input, path, replacement) {
|
|
3099
|
+
const parts = path.split(".");
|
|
3100
|
+
let cursor = input;
|
|
3101
|
+
for (const part of parts.slice(0, -1)) {
|
|
3102
|
+
const next = cursor[part];
|
|
3103
|
+
if (!next || typeof next !== "object")
|
|
3104
|
+
return;
|
|
3105
|
+
cursor = next;
|
|
3106
|
+
}
|
|
3107
|
+
const last = parts.at(-1);
|
|
3108
|
+
if (last && last in cursor)
|
|
3109
|
+
cursor[last] = replacement;
|
|
3110
|
+
}
|
|
3111
|
+
function normalizeTime(value) {
|
|
3112
|
+
if (!value)
|
|
3113
|
+
return new Date().toISOString();
|
|
3114
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
3115
|
+
}
|
|
3116
|
+
function normalizeRetryPolicy(policy) {
|
|
3117
|
+
return {
|
|
3118
|
+
maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
|
|
3119
|
+
backoffMs: Math.max(0, policy?.backoffMs ?? 250),
|
|
3120
|
+
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
3121
|
+
};
|
|
3122
|
+
}
|
|
3123
|
+
function parseJsonObject(value, fallback) {
|
|
3124
|
+
if (!value)
|
|
3125
|
+
return fallback;
|
|
3126
|
+
const parsed = JSON.parse(value);
|
|
3127
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3128
|
+
throw new Error("Expected a JSON object");
|
|
3129
|
+
}
|
|
3130
|
+
return parsed;
|
|
3131
|
+
}
|
|
3132
|
+
function parseHeaders(values) {
|
|
3133
|
+
if (!values?.length)
|
|
3134
|
+
return;
|
|
3135
|
+
const headers = {};
|
|
3136
|
+
for (const value of values) {
|
|
3137
|
+
const separator = value.indexOf("=");
|
|
3138
|
+
if (separator === -1)
|
|
3139
|
+
throw new Error(`Invalid header, expected name=value: ${value}`);
|
|
3140
|
+
headers[value.slice(0, separator)] = value.slice(separator + 1);
|
|
3141
|
+
}
|
|
3142
|
+
return headers;
|
|
3143
|
+
}
|
|
3144
|
+
function parseFilter(options) {
|
|
3145
|
+
const filter2 = {};
|
|
3146
|
+
if (options.source)
|
|
3147
|
+
filter2.source = options.source;
|
|
3148
|
+
if (options.type)
|
|
3149
|
+
filter2.type = options.type;
|
|
3150
|
+
if (options.subject)
|
|
3151
|
+
filter2.subject = options.subject;
|
|
3152
|
+
if (options.severity)
|
|
3153
|
+
filter2.severity = options.severity;
|
|
3154
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
3155
|
+
}
|
|
3156
|
+
function createClient(options) {
|
|
3157
|
+
if (options.createClient)
|
|
3158
|
+
return options.createClient();
|
|
3159
|
+
return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
|
|
3160
|
+
}
|
|
3161
|
+
function print(value, json, text) {
|
|
3162
|
+
if (json)
|
|
3163
|
+
console.log(JSON.stringify(value, null, 2));
|
|
3164
|
+
else
|
|
3165
|
+
console.log(text);
|
|
3166
|
+
}
|
|
3167
|
+
function registerWebhookCommands(program2, options) {
|
|
3168
|
+
const webhooks = program2.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
|
|
3169
|
+
webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions) => {
|
|
3170
|
+
const timestamp = new Date().toISOString();
|
|
3171
|
+
const channel = {
|
|
3172
|
+
id: actionOptions.id,
|
|
3173
|
+
name: actionOptions.name,
|
|
3174
|
+
enabled: !actionOptions.disabled,
|
|
3175
|
+
transport: actionOptions.transport,
|
|
3176
|
+
filters: parseFilter(actionOptions),
|
|
3177
|
+
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
3178
|
+
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
3179
|
+
createdAt: timestamp,
|
|
3180
|
+
updatedAt: timestamp
|
|
3181
|
+
};
|
|
3182
|
+
if (actionOptions.transport === "webhook") {
|
|
3183
|
+
channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
|
|
3184
|
+
} else if (actionOptions.transport === "command") {
|
|
3185
|
+
channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
|
|
3186
|
+
} else {
|
|
3187
|
+
throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
|
|
3188
|
+
}
|
|
3189
|
+
const saved = await createClient(options).addChannel(channel);
|
|
3190
|
+
print(sanitizeChannelForOutput(saved), Boolean(actionOptions.json), `Added ${saved.transport} channel ${saved.id}`);
|
|
3191
|
+
});
|
|
3192
|
+
webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
|
|
3193
|
+
const channels = await createClient(options).listChannels();
|
|
3194
|
+
if (actionOptions.json) {
|
|
3195
|
+
console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
if (!channels.length) {
|
|
3199
|
+
console.log("No channels configured.");
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
for (const channel of channels) {
|
|
3203
|
+
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
3204
|
+
}
|
|
3205
|
+
});
|
|
3206
|
+
webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions) => {
|
|
3207
|
+
const removed = await createClient(options).removeChannel(id);
|
|
3208
|
+
print({ removed }, Boolean(actionOptions.json), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
3209
|
+
});
|
|
3210
|
+
webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions) => {
|
|
3211
|
+
const result = await createClient(options).testChannel(id, {
|
|
3212
|
+
source: options.source,
|
|
3213
|
+
type: actionOptions.type,
|
|
3214
|
+
subject: actionOptions.subject ?? id,
|
|
3215
|
+
message: actionOptions.message,
|
|
3216
|
+
data: parseJsonObject(actionOptions.data, { test: true })
|
|
3217
|
+
});
|
|
3218
|
+
print(result, Boolean(actionOptions.json), `${result.status}: ${result.channelId}`);
|
|
3219
|
+
});
|
|
3220
|
+
return webhooks;
|
|
3221
|
+
}
|
|
3222
|
+
function registerEventCommands(program2, options) {
|
|
3223
|
+
const events = program2.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
|
|
3224
|
+
events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions) => {
|
|
3225
|
+
const result = await createClient(options).emit({
|
|
3226
|
+
source: actionOptions.source ?? options.source,
|
|
3227
|
+
type,
|
|
3228
|
+
subject: actionOptions.subject,
|
|
3229
|
+
severity: actionOptions.severity,
|
|
3230
|
+
message: actionOptions.message,
|
|
3231
|
+
dedupeKey: actionOptions.dedupeKey,
|
|
3232
|
+
data: parseJsonObject(actionOptions.data, {}),
|
|
3233
|
+
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
3234
|
+
}, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
|
|
3235
|
+
print(result, Boolean(actionOptions.json), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
|
|
3236
|
+
});
|
|
3237
|
+
events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
|
|
3238
|
+
let rows = await createClient(options).listEvents();
|
|
3239
|
+
if (actionOptions.source)
|
|
3240
|
+
rows = rows.filter((event) => event.source === actionOptions.source);
|
|
3241
|
+
if (actionOptions.type)
|
|
3242
|
+
rows = rows.filter((event) => event.type === actionOptions.type);
|
|
3243
|
+
if (actionOptions.limit)
|
|
3244
|
+
rows = rows.slice(-actionOptions.limit);
|
|
3245
|
+
if (actionOptions.json) {
|
|
3246
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
3247
|
+
return;
|
|
3248
|
+
}
|
|
3249
|
+
if (!rows.length) {
|
|
3250
|
+
console.log("No events recorded.");
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
for (const event of rows)
|
|
3254
|
+
console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
|
|
3255
|
+
});
|
|
3256
|
+
events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
|
|
3257
|
+
const result = await createClient(options).replay({
|
|
3258
|
+
eventId: actionOptions.id,
|
|
3259
|
+
source: actionOptions.source,
|
|
3260
|
+
type: actionOptions.type,
|
|
3261
|
+
dryRun: actionOptions.dryRun
|
|
3262
|
+
});
|
|
3263
|
+
print(result, Boolean(actionOptions.json), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
|
|
3264
|
+
});
|
|
3265
|
+
return events;
|
|
3266
|
+
}
|
|
3267
|
+
function registerEventsCommands(program2, options) {
|
|
3268
|
+
registerWebhookCommands(program2, options);
|
|
3269
|
+
registerEventCommands(program2, options);
|
|
3270
|
+
}
|
|
3271
|
+
function parseNumber(value) {
|
|
3272
|
+
const parsed = Number(value);
|
|
3273
|
+
if (!Number.isFinite(parsed))
|
|
3274
|
+
throw new Error(`Expected a number, got ${value}`);
|
|
3275
|
+
return parsed;
|
|
3276
|
+
}
|
|
3277
|
+
function collectValues(value, previous) {
|
|
3278
|
+
previous.push(value);
|
|
3279
|
+
return previous;
|
|
3280
|
+
}
|
|
3281
|
+
|
|
2604
3282
|
// src/cli/index.ts
|
|
2605
3283
|
import { execFileSync } from "child_process";
|
|
2606
3284
|
|
|
@@ -3094,14 +3772,14 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
|
3094
3772
|
var source_default = chalk;
|
|
3095
3773
|
|
|
3096
3774
|
// src/version.ts
|
|
3097
|
-
import { existsSync, readFileSync } from "fs";
|
|
3098
|
-
import { dirname, join } from "path";
|
|
3775
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
3776
|
+
import { dirname, join as join2 } from "path";
|
|
3099
3777
|
import { fileURLToPath } from "url";
|
|
3100
3778
|
function getPackageVersion() {
|
|
3101
3779
|
try {
|
|
3102
3780
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
3103
|
-
const candidates = [
|
|
3104
|
-
const pkgPath = candidates.find((candidate) =>
|
|
3781
|
+
const candidates = [join2(here, "..", "package.json"), join2(here, "..", "..", "package.json")];
|
|
3782
|
+
const pkgPath = candidates.find((candidate) => existsSync2(candidate));
|
|
3105
3783
|
if (!pkgPath) {
|
|
3106
3784
|
return "0.0.0";
|
|
3107
3785
|
}
|
|
@@ -3112,8 +3790,8 @@ function getPackageVersion() {
|
|
|
3112
3790
|
}
|
|
3113
3791
|
|
|
3114
3792
|
// src/manifests.ts
|
|
3115
|
-
import { existsSync as
|
|
3116
|
-
import { arch, homedir, hostname, platform, userInfo } from "os";
|
|
3793
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
3794
|
+
import { arch, homedir as homedir2, hostname, platform, userInfo } from "os";
|
|
3117
3795
|
import { dirname as dirname3 } from "path";
|
|
3118
3796
|
|
|
3119
3797
|
// node_modules/zod/v3/external.js
|
|
@@ -7127,7 +7805,7 @@ var fleetSchema = exports_external.object({
|
|
|
7127
7805
|
machines: exports_external.array(machineSchema)
|
|
7128
7806
|
});
|
|
7129
7807
|
function detectWorkspacePath() {
|
|
7130
|
-
const home =
|
|
7808
|
+
const home = homedir2();
|
|
7131
7809
|
if (platform() === "darwin") {
|
|
7132
7810
|
return `${home}/Workspace`;
|
|
7133
7811
|
}
|
|
@@ -7151,7 +7829,7 @@ function getDefaultManifest() {
|
|
|
7151
7829
|
};
|
|
7152
7830
|
}
|
|
7153
7831
|
function readManifest(path = getManifestPath()) {
|
|
7154
|
-
if (!
|
|
7832
|
+
if (!existsSync4(path)) {
|
|
7155
7833
|
return getDefaultManifest();
|
|
7156
7834
|
}
|
|
7157
7835
|
const raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
@@ -7232,7 +7910,7 @@ function manifestValidate() {
|
|
|
7232
7910
|
}
|
|
7233
7911
|
|
|
7234
7912
|
// src/commands/setup.ts
|
|
7235
|
-
import { homedir as
|
|
7913
|
+
import { homedir as homedir3 } from "os";
|
|
7236
7914
|
init_db();
|
|
7237
7915
|
function quote(value) {
|
|
7238
7916
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -7303,7 +7981,7 @@ function buildSetupPlan(machineId) {
|
|
|
7303
7981
|
const target = selected || {
|
|
7304
7982
|
id: currentMachineId,
|
|
7305
7983
|
platform: "linux",
|
|
7306
|
-
workspacePath: `${
|
|
7984
|
+
workspacePath: `${homedir3()}/workspace`
|
|
7307
7985
|
};
|
|
7308
7986
|
const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
|
|
7309
7987
|
return {
|
|
@@ -7349,8 +8027,8 @@ function runSetup(machineId, options = {}) {
|
|
|
7349
8027
|
}
|
|
7350
8028
|
|
|
7351
8029
|
// src/commands/backup.ts
|
|
7352
|
-
import { homedir as
|
|
7353
|
-
import { join as
|
|
8030
|
+
import { homedir as homedir4, hostname as hostname3 } from "os";
|
|
8031
|
+
import { join as join4 } from "path";
|
|
7354
8032
|
var MACHINES_BACKUP_BUCKET_ENV = "HASNA_MACHINES_S3_BUCKET";
|
|
7355
8033
|
var MACHINES_BACKUP_BUCKET_FALLBACK_ENV = "MACHINES_S3_BUCKET";
|
|
7356
8034
|
var MACHINES_BACKUP_PREFIX_ENV = "HASNA_MACHINES_S3_PREFIX";
|
|
@@ -7398,16 +8076,16 @@ function resolveBackupTarget(options = {}) {
|
|
|
7398
8076
|
};
|
|
7399
8077
|
}
|
|
7400
8078
|
function defaultBackupSources() {
|
|
7401
|
-
const home =
|
|
8079
|
+
const home = homedir4();
|
|
7402
8080
|
return [
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
8081
|
+
join4(home, ".hasna"),
|
|
8082
|
+
join4(home, ".ssh"),
|
|
8083
|
+
join4(home, ".secrets")
|
|
7406
8084
|
];
|
|
7407
8085
|
}
|
|
7408
8086
|
function buildBackupPlan(bucket, prefix) {
|
|
7409
8087
|
const target = resolveBackupTarget({ bucket, prefix });
|
|
7410
|
-
const archivePath =
|
|
8088
|
+
const archivePath = join4(homedir4(), ".hasna", "machines", "backup.tgz");
|
|
7411
8089
|
const sources = defaultBackupSources();
|
|
7412
8090
|
const steps = [
|
|
7413
8091
|
{
|
|
@@ -7458,21 +8136,21 @@ function runBackup(bucket, prefix, options = {}) {
|
|
|
7458
8136
|
}
|
|
7459
8137
|
|
|
7460
8138
|
// src/commands/cert.ts
|
|
7461
|
-
import { homedir as
|
|
7462
|
-
import { join as
|
|
8139
|
+
import { homedir as homedir5, platform as platform2 } from "os";
|
|
8140
|
+
import { join as join5 } from "path";
|
|
7463
8141
|
function quote3(value) {
|
|
7464
8142
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7465
8143
|
}
|
|
7466
8144
|
function certDir() {
|
|
7467
|
-
return
|
|
8145
|
+
return join5(homedir5(), ".hasna", "machines", "certs");
|
|
7468
8146
|
}
|
|
7469
8147
|
function buildCertPlan(domains) {
|
|
7470
8148
|
if (domains.length === 0) {
|
|
7471
8149
|
throw new Error("At least one domain is required.");
|
|
7472
8150
|
}
|
|
7473
8151
|
const primary = domains[0];
|
|
7474
|
-
const certPath =
|
|
7475
|
-
const keyPath =
|
|
8152
|
+
const certPath = join5(certDir(), `${primary}.pem`);
|
|
8153
|
+
const keyPath = join5(certDir(), `${primary}-key.pem`);
|
|
7476
8154
|
const steps = [];
|
|
7477
8155
|
if (platform2() === "darwin") {
|
|
7478
8156
|
steps.push({
|
|
@@ -7537,14 +8215,14 @@ function runCertPlan(domains, options = {}) {
|
|
|
7537
8215
|
|
|
7538
8216
|
// src/commands/dns.ts
|
|
7539
8217
|
init_paths();
|
|
7540
|
-
import { existsSync as
|
|
7541
|
-
import { join as
|
|
8218
|
+
import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
8219
|
+
import { join as join6 } from "path";
|
|
7542
8220
|
function getDnsPath() {
|
|
7543
|
-
return
|
|
8221
|
+
return join6(getDataDir(), "dns.json");
|
|
7544
8222
|
}
|
|
7545
8223
|
function readMappings() {
|
|
7546
8224
|
const path = getDnsPath();
|
|
7547
|
-
if (!
|
|
8225
|
+
if (!existsSync5(path))
|
|
7548
8226
|
return [];
|
|
7549
8227
|
return JSON.parse(readFileSync3(path, "utf8"));
|
|
7550
8228
|
}
|
|
@@ -7573,10 +8251,10 @@ function renderDomainMapping(domain) {
|
|
|
7573
8251
|
hostsEntry: `${entry.targetHost} ${entry.domain}`,
|
|
7574
8252
|
caddySnippet: `${entry.domain} {
|
|
7575
8253
|
reverse_proxy 127.0.0.1:${entry.port}
|
|
7576
|
-
tls ${
|
|
8254
|
+
tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
|
|
7577
8255
|
}`,
|
|
7578
|
-
certPath:
|
|
7579
|
-
keyPath:
|
|
8256
|
+
certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
|
|
8257
|
+
keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
|
|
7580
8258
|
};
|
|
7581
8259
|
}
|
|
7582
8260
|
|
|
@@ -7628,7 +8306,7 @@ import { hostname as hostname5 } from "os";
|
|
|
7628
8306
|
|
|
7629
8307
|
// src/topology.ts
|
|
7630
8308
|
init_db();
|
|
7631
|
-
import { existsSync as
|
|
8309
|
+
import { existsSync as existsSync6 } from "fs";
|
|
7632
8310
|
import { arch as arch2, hostname as hostname4, platform as platform3, userInfo as userInfo2 } from "os";
|
|
7633
8311
|
import { spawnSync } from "child_process";
|
|
7634
8312
|
init_paths();
|
|
@@ -7819,7 +8497,7 @@ function buildEntry(input) {
|
|
|
7819
8497
|
};
|
|
7820
8498
|
}
|
|
7821
8499
|
function discoverMachineTopology(options = {}) {
|
|
7822
|
-
const
|
|
8500
|
+
const now2 = options.now ?? new Date;
|
|
7823
8501
|
const runner = options.runner ?? defaultRunner;
|
|
7824
8502
|
const warnings = [];
|
|
7825
8503
|
const manifest = readManifest();
|
|
@@ -7851,11 +8529,11 @@ function discoverMachineTopology(options = {}) {
|
|
|
7851
8529
|
version: getPackageVersion()
|
|
7852
8530
|
},
|
|
7853
8531
|
capabilities: getMachinesConsumerCapabilities(),
|
|
7854
|
-
generated_at:
|
|
8532
|
+
generated_at: now2.toISOString(),
|
|
7855
8533
|
local_machine_id: localMachineId,
|
|
7856
8534
|
local_hostname: hostname4(),
|
|
7857
8535
|
current_platform: normalizePlatform2(),
|
|
7858
|
-
manifest_path_known:
|
|
8536
|
+
manifest_path_known: existsSync6(getManifestPath()),
|
|
7859
8537
|
machines,
|
|
7860
8538
|
warnings
|
|
7861
8539
|
};
|
|
@@ -7974,11 +8652,11 @@ function cacheability(input) {
|
|
|
7974
8652
|
};
|
|
7975
8653
|
}
|
|
7976
8654
|
function resolveMachineRoute(machineId, options = {}) {
|
|
7977
|
-
const
|
|
8655
|
+
const now2 = options.now ?? new Date;
|
|
7978
8656
|
const topology = options.topology ?? discoverMachineTopology(options);
|
|
7979
8657
|
const warnings = [...topology.warnings];
|
|
7980
8658
|
const { machine, matchedBy } = findRouteMachine(topology, machineId);
|
|
7981
|
-
const generatedAt =
|
|
8659
|
+
const generatedAt = now2.toISOString();
|
|
7982
8660
|
if (!machine) {
|
|
7983
8661
|
warnings.push(`machine_not_found:${machineId}`);
|
|
7984
8662
|
return {
|
|
@@ -8004,8 +8682,8 @@ function resolveMachineRoute(machineId, options = {}) {
|
|
|
8004
8682
|
},
|
|
8005
8683
|
cacheability: cacheability({
|
|
8006
8684
|
ok: false,
|
|
8007
|
-
observedAt:
|
|
8008
|
-
now,
|
|
8685
|
+
observedAt: now2,
|
|
8686
|
+
now: now2,
|
|
8009
8687
|
ttlMs: options.resolverTtlMs,
|
|
8010
8688
|
authority: "unresolved",
|
|
8011
8689
|
confidence: "none",
|
|
@@ -8042,8 +8720,8 @@ function resolveMachineRoute(machineId, options = {}) {
|
|
|
8042
8720
|
},
|
|
8043
8721
|
cacheability: cacheability({
|
|
8044
8722
|
ok,
|
|
8045
|
-
observedAt:
|
|
8046
|
-
now,
|
|
8723
|
+
observedAt: now2,
|
|
8724
|
+
now: now2,
|
|
8047
8725
|
ttlMs: options.resolverTtlMs,
|
|
8048
8726
|
authority: routeAuthority({ machine, selectedHint, matchedBy }),
|
|
8049
8727
|
confidence,
|
|
@@ -8130,7 +8808,7 @@ function canCheckPathForMachine(machine, localMachineId) {
|
|
|
8130
8808
|
function checkedPathExists(path, check) {
|
|
8131
8809
|
if (!path || !check)
|
|
8132
8810
|
return null;
|
|
8133
|
-
return
|
|
8811
|
+
return existsSync6(path);
|
|
8134
8812
|
}
|
|
8135
8813
|
function repairHint(input) {
|
|
8136
8814
|
const command = [
|
|
@@ -8345,11 +9023,11 @@ function metadataKeysForDiagnostics(metadata) {
|
|
|
8345
9023
|
return Object.keys(metadata).filter((key) => !/(secret|token|key|password|credential)/i.test(key)).sort();
|
|
8346
9024
|
}
|
|
8347
9025
|
function resolveMachineWorkspace(options) {
|
|
8348
|
-
const
|
|
9026
|
+
const now2 = options.now ?? new Date;
|
|
8349
9027
|
const topology = options.topology ?? discoverMachineTopology(options);
|
|
8350
9028
|
const warnings = [...topology.warnings];
|
|
8351
9029
|
const { machine, matchedBy } = findRouteMachine(topology, options.machineId);
|
|
8352
|
-
const generatedAt =
|
|
9030
|
+
const generatedAt = now2.toISOString();
|
|
8353
9031
|
const repoName = options.repoName ?? options.projectId;
|
|
8354
9032
|
const openFilesRepoName = options.openFilesRepoName ?? "open-files";
|
|
8355
9033
|
if (!machine) {
|
|
@@ -8378,8 +9056,8 @@ function resolveMachineWorkspace(options) {
|
|
|
8378
9056
|
},
|
|
8379
9057
|
cacheability: cacheability({
|
|
8380
9058
|
ok: false,
|
|
8381
|
-
observedAt:
|
|
8382
|
-
now,
|
|
9059
|
+
observedAt: now2,
|
|
9060
|
+
now: now2,
|
|
8383
9061
|
ttlMs: options.resolverTtlMs,
|
|
8384
9062
|
authority: "unresolved",
|
|
8385
9063
|
confidence: "none",
|
|
@@ -8451,8 +9129,8 @@ function resolveMachineWorkspace(options) {
|
|
|
8451
9129
|
},
|
|
8452
9130
|
cacheability: cacheability({
|
|
8453
9131
|
ok: workspaceOk,
|
|
8454
|
-
observedAt:
|
|
8455
|
-
now,
|
|
9132
|
+
observedAt: now2,
|
|
9133
|
+
now: now2,
|
|
8456
9134
|
ttlMs: options.resolverTtlMs,
|
|
8457
9135
|
authority: workspaceAuthority(workspacePaths),
|
|
8458
9136
|
confidence: workspaceOk ? "medium" : "none",
|
|
@@ -8850,7 +9528,7 @@ function runTailscaleInstall(machineId, options = {}) {
|
|
|
8850
9528
|
}
|
|
8851
9529
|
|
|
8852
9530
|
// src/commands/notifications.ts
|
|
8853
|
-
import { existsSync as
|
|
9531
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
8854
9532
|
init_paths();
|
|
8855
9533
|
var notificationChannelSchema = exports_external.object({
|
|
8856
9534
|
id: exports_external.string(),
|
|
@@ -8933,7 +9611,7 @@ ${message}
|
|
|
8933
9611
|
}
|
|
8934
9612
|
throw new Error("No local email transport available. Install sendmail or mail.");
|
|
8935
9613
|
}
|
|
8936
|
-
async function
|
|
9614
|
+
async function dispatchWebhook2(channel, event, message) {
|
|
8937
9615
|
const response = await fetch(channel.target, {
|
|
8938
9616
|
method: "POST",
|
|
8939
9617
|
headers: {
|
|
@@ -8958,7 +9636,7 @@ async function dispatchWebhook(channel, event, message) {
|
|
|
8958
9636
|
detail: `Webhook accepted with HTTP ${response.status}`
|
|
8959
9637
|
};
|
|
8960
9638
|
}
|
|
8961
|
-
async function
|
|
9639
|
+
async function dispatchCommand2(channel, event, message) {
|
|
8962
9640
|
const result = Bun.spawnSync(["bash", "-lc", channel.target], {
|
|
8963
9641
|
stdout: "pipe",
|
|
8964
9642
|
stderr: "pipe",
|
|
@@ -8981,7 +9659,7 @@ async function dispatchCommand(channel, event, message) {
|
|
|
8981
9659
|
detail: stdout || "Command completed successfully"
|
|
8982
9660
|
};
|
|
8983
9661
|
}
|
|
8984
|
-
async function
|
|
9662
|
+
async function dispatchChannel2(channel, event, message) {
|
|
8985
9663
|
if (!channel.enabled) {
|
|
8986
9664
|
return {
|
|
8987
9665
|
channelId: channel.id,
|
|
@@ -8995,9 +9673,9 @@ async function dispatchChannel(channel, event, message) {
|
|
|
8995
9673
|
return dispatchEmail(channel, event, message);
|
|
8996
9674
|
}
|
|
8997
9675
|
if (channel.type === "webhook") {
|
|
8998
|
-
return
|
|
9676
|
+
return dispatchWebhook2(channel, event, message);
|
|
8999
9677
|
}
|
|
9000
|
-
return
|
|
9678
|
+
return dispatchCommand2(channel, event, message);
|
|
9001
9679
|
}
|
|
9002
9680
|
function getDefaultNotificationConfig() {
|
|
9003
9681
|
return {
|
|
@@ -9007,7 +9685,7 @@ function getDefaultNotificationConfig() {
|
|
|
9007
9685
|
};
|
|
9008
9686
|
}
|
|
9009
9687
|
function readNotificationConfig(path = getNotificationsPath()) {
|
|
9010
|
-
if (!
|
|
9688
|
+
if (!existsSync7(path)) {
|
|
9011
9689
|
return getDefaultNotificationConfig();
|
|
9012
9690
|
}
|
|
9013
9691
|
return notificationConfigSchema.parse(JSON.parse(readFileSync4(path, "utf8")));
|
|
@@ -9052,7 +9730,7 @@ async function dispatchNotificationEvent(event, message, options = {}) {
|
|
|
9052
9730
|
const deliveries = [];
|
|
9053
9731
|
for (const channel of channels) {
|
|
9054
9732
|
try {
|
|
9055
|
-
deliveries.push(await
|
|
9733
|
+
deliveries.push(await dispatchChannel2(channel, event, message));
|
|
9056
9734
|
} catch (error) {
|
|
9057
9735
|
deliveries.push({
|
|
9058
9736
|
channelId: channel.id,
|
|
@@ -9087,7 +9765,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
|
|
|
9087
9765
|
if (!options.yes) {
|
|
9088
9766
|
throw new Error("Notification test execution requires --yes.");
|
|
9089
9767
|
}
|
|
9090
|
-
const delivery = await
|
|
9768
|
+
const delivery = await dispatchChannel2(channel, event, message);
|
|
9091
9769
|
return {
|
|
9092
9770
|
channelId,
|
|
9093
9771
|
mode: "apply",
|
|
@@ -9152,6 +9830,616 @@ function listPorts(machineId) {
|
|
|
9152
9830
|
};
|
|
9153
9831
|
}
|
|
9154
9832
|
|
|
9833
|
+
// src/commands/runtime.ts
|
|
9834
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
9835
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
9836
|
+
|
|
9837
|
+
// node_modules/@hasna/events/dist/index.js
|
|
9838
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
9839
|
+
import { existsSync as existsSync8 } from "fs";
|
|
9840
|
+
import { homedir as homedir6 } from "os";
|
|
9841
|
+
import { join as join7 } from "path";
|
|
9842
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
9843
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
9844
|
+
import { spawn as spawn2 } from "child_process";
|
|
9845
|
+
import { randomUUID as randomUUID22 } from "crypto";
|
|
9846
|
+
function getPathValue2(input, path) {
|
|
9847
|
+
return path.split(".").reduce((value, part) => {
|
|
9848
|
+
if (value && typeof value === "object" && part in value) {
|
|
9849
|
+
return value[part];
|
|
9850
|
+
}
|
|
9851
|
+
return;
|
|
9852
|
+
}, input);
|
|
9853
|
+
}
|
|
9854
|
+
function wildcardToRegExp2(pattern) {
|
|
9855
|
+
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, ".*");
|
|
9856
|
+
return new RegExp(`^${escaped}$`);
|
|
9857
|
+
}
|
|
9858
|
+
function matchString2(value, matcher) {
|
|
9859
|
+
if (matcher === undefined)
|
|
9860
|
+
return true;
|
|
9861
|
+
if (value === undefined)
|
|
9862
|
+
return false;
|
|
9863
|
+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
9864
|
+
return matchers.some((item) => wildcardToRegExp2(item).test(value));
|
|
9865
|
+
}
|
|
9866
|
+
function matchRecord2(input, matcher) {
|
|
9867
|
+
if (!matcher)
|
|
9868
|
+
return true;
|
|
9869
|
+
return Object.entries(matcher).every(([path, expected]) => {
|
|
9870
|
+
const actual = getPathValue2(input, path);
|
|
9871
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
9872
|
+
return matchString2(actual === undefined ? undefined : String(actual), expected);
|
|
9873
|
+
}
|
|
9874
|
+
return actual === expected;
|
|
9875
|
+
});
|
|
9876
|
+
}
|
|
9877
|
+
function eventMatchesFilter2(event, filter) {
|
|
9878
|
+
return matchString2(event.source, filter.source) && matchString2(event.type, filter.type) && matchString2(event.subject, filter.subject) && matchString2(event.severity, filter.severity) && matchRecord2(event.data, filter.data) && matchRecord2(event.metadata, filter.metadata);
|
|
9879
|
+
}
|
|
9880
|
+
function channelMatchesEvent2(channel, event) {
|
|
9881
|
+
if (!channel.enabled)
|
|
9882
|
+
return false;
|
|
9883
|
+
if (!channel.filters || channel.filters.length === 0)
|
|
9884
|
+
return true;
|
|
9885
|
+
return channel.filters.some((filter) => eventMatchesFilter2(event, filter));
|
|
9886
|
+
}
|
|
9887
|
+
var HASNA_EVENTS_DIR_ENV2 = "HASNA_EVENTS_DIR";
|
|
9888
|
+
var HASNA_EVENTS_HOME_ENV2 = "HASNA_EVENTS_HOME";
|
|
9889
|
+
function getEventsDataDir2(override) {
|
|
9890
|
+
return override || process.env[HASNA_EVENTS_DIR_ENV2] || process.env[HASNA_EVENTS_HOME_ENV2] || join7(homedir6(), ".hasna", "events");
|
|
9891
|
+
}
|
|
9892
|
+
|
|
9893
|
+
class JsonEventsStore2 {
|
|
9894
|
+
dataDir;
|
|
9895
|
+
channelsPath;
|
|
9896
|
+
eventsPath;
|
|
9897
|
+
deliveriesPath;
|
|
9898
|
+
constructor(dataDir = getEventsDataDir2()) {
|
|
9899
|
+
this.dataDir = dataDir;
|
|
9900
|
+
this.channelsPath = join7(dataDir, "channels.json");
|
|
9901
|
+
this.eventsPath = join7(dataDir, "events.json");
|
|
9902
|
+
this.deliveriesPath = join7(dataDir, "deliveries.json");
|
|
9903
|
+
}
|
|
9904
|
+
async init() {
|
|
9905
|
+
await mkdir2(this.dataDir, { recursive: true, mode: 448 });
|
|
9906
|
+
await chmod2(this.dataDir, 448).catch(() => {
|
|
9907
|
+
return;
|
|
9908
|
+
});
|
|
9909
|
+
await this.ensureArrayFile(this.channelsPath);
|
|
9910
|
+
await this.ensureArrayFile(this.eventsPath);
|
|
9911
|
+
await this.ensureArrayFile(this.deliveriesPath);
|
|
9912
|
+
}
|
|
9913
|
+
async addChannel(channel) {
|
|
9914
|
+
await this.init();
|
|
9915
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
9916
|
+
const index = channels.findIndex((item) => item.id === channel.id);
|
|
9917
|
+
if (index >= 0) {
|
|
9918
|
+
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
9919
|
+
} else {
|
|
9920
|
+
channels.push(channel);
|
|
9921
|
+
}
|
|
9922
|
+
await this.writeJson(this.channelsPath, channels);
|
|
9923
|
+
return index >= 0 ? channels[index] : channel;
|
|
9924
|
+
}
|
|
9925
|
+
async listChannels() {
|
|
9926
|
+
await this.init();
|
|
9927
|
+
return this.readJson(this.channelsPath, []);
|
|
9928
|
+
}
|
|
9929
|
+
async getChannel(id) {
|
|
9930
|
+
const channels = await this.listChannels();
|
|
9931
|
+
return channels.find((channel) => channel.id === id);
|
|
9932
|
+
}
|
|
9933
|
+
async removeChannel(id) {
|
|
9934
|
+
await this.init();
|
|
9935
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
9936
|
+
const next = channels.filter((channel) => channel.id !== id);
|
|
9937
|
+
await this.writeJson(this.channelsPath, next);
|
|
9938
|
+
return next.length !== channels.length;
|
|
9939
|
+
}
|
|
9940
|
+
async appendEvent(event) {
|
|
9941
|
+
await this.init();
|
|
9942
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
9943
|
+
events.push(event);
|
|
9944
|
+
await this.writeJson(this.eventsPath, events);
|
|
9945
|
+
return event;
|
|
9946
|
+
}
|
|
9947
|
+
async listEvents() {
|
|
9948
|
+
await this.init();
|
|
9949
|
+
return this.readJson(this.eventsPath, []);
|
|
9950
|
+
}
|
|
9951
|
+
async findEventByIdentity(identity) {
|
|
9952
|
+
const events = await this.listEvents();
|
|
9953
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
9954
|
+
}
|
|
9955
|
+
async appendDelivery(result) {
|
|
9956
|
+
await this.init();
|
|
9957
|
+
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
9958
|
+
deliveries.push(result);
|
|
9959
|
+
await this.writeJson(this.deliveriesPath, deliveries);
|
|
9960
|
+
return result;
|
|
9961
|
+
}
|
|
9962
|
+
async listDeliveries() {
|
|
9963
|
+
await this.init();
|
|
9964
|
+
return this.readJson(this.deliveriesPath, []);
|
|
9965
|
+
}
|
|
9966
|
+
async exportData() {
|
|
9967
|
+
return {
|
|
9968
|
+
channels: await this.listChannels(),
|
|
9969
|
+
events: await this.listEvents(),
|
|
9970
|
+
deliveries: await this.listDeliveries()
|
|
9971
|
+
};
|
|
9972
|
+
}
|
|
9973
|
+
async ensureArrayFile(path) {
|
|
9974
|
+
if (!existsSync8(path)) {
|
|
9975
|
+
await writeFile2(path, `[]
|
|
9976
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
9977
|
+
}
|
|
9978
|
+
await chmod2(path, 384).catch(() => {
|
|
9979
|
+
return;
|
|
9980
|
+
});
|
|
9981
|
+
}
|
|
9982
|
+
async readJson(path, fallback) {
|
|
9983
|
+
try {
|
|
9984
|
+
const raw = await readFile2(path, "utf-8");
|
|
9985
|
+
if (!raw.trim())
|
|
9986
|
+
return fallback;
|
|
9987
|
+
return JSON.parse(raw);
|
|
9988
|
+
} catch (error) {
|
|
9989
|
+
if (error.code === "ENOENT")
|
|
9990
|
+
return fallback;
|
|
9991
|
+
throw error;
|
|
9992
|
+
}
|
|
9993
|
+
}
|
|
9994
|
+
async writeJson(path, value) {
|
|
9995
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
9996
|
+
await writeFile2(tempPath, `${JSON.stringify(value, null, 2)}
|
|
9997
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
9998
|
+
await rename2(tempPath, path);
|
|
9999
|
+
await chmod2(path, 384).catch(() => {
|
|
10000
|
+
return;
|
|
10001
|
+
});
|
|
10002
|
+
}
|
|
10003
|
+
}
|
|
10004
|
+
var DEFAULT_SIGNATURE_TOLERANCE_MS2 = 5 * 60 * 1000;
|
|
10005
|
+
function buildSignatureBase2(timestamp, body) {
|
|
10006
|
+
return `${timestamp}.${body}`;
|
|
10007
|
+
}
|
|
10008
|
+
function signPayload2(secret, timestamp, body) {
|
|
10009
|
+
const digest = createHmac2("sha256", secret).update(buildSignatureBase2(timestamp, body)).digest("hex");
|
|
10010
|
+
return `sha256=${digest}`;
|
|
10011
|
+
}
|
|
10012
|
+
function now2() {
|
|
10013
|
+
return new Date().toISOString();
|
|
10014
|
+
}
|
|
10015
|
+
function truncate2(value, max = 4096) {
|
|
10016
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
10017
|
+
}
|
|
10018
|
+
function buildWebhookRequest2(event, channel) {
|
|
10019
|
+
if (!channel.webhook)
|
|
10020
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
10021
|
+
const body = JSON.stringify(event);
|
|
10022
|
+
const timestamp = event.time;
|
|
10023
|
+
const headers = {
|
|
10024
|
+
"Content-Type": "application/json",
|
|
10025
|
+
"User-Agent": "@hasna/events",
|
|
10026
|
+
"X-Hasna-Event-Id": event.id,
|
|
10027
|
+
"X-Hasna-Event-Type": event.type,
|
|
10028
|
+
"X-Hasna-Timestamp": timestamp,
|
|
10029
|
+
...channel.webhook.headers
|
|
10030
|
+
};
|
|
10031
|
+
if (channel.webhook.secret) {
|
|
10032
|
+
headers["X-Hasna-Signature"] = signPayload2(channel.webhook.secret, timestamp, body);
|
|
10033
|
+
}
|
|
10034
|
+
return { body, headers };
|
|
10035
|
+
}
|
|
10036
|
+
async function dispatchWebhook3(event, channel, options = {}) {
|
|
10037
|
+
if (!channel.webhook)
|
|
10038
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
10039
|
+
const startedAt = now2();
|
|
10040
|
+
const { body, headers } = buildWebhookRequest2(event, channel);
|
|
10041
|
+
const controller = new AbortController;
|
|
10042
|
+
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
10043
|
+
try {
|
|
10044
|
+
const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
|
|
10045
|
+
method: "POST",
|
|
10046
|
+
headers,
|
|
10047
|
+
body,
|
|
10048
|
+
signal: controller.signal
|
|
10049
|
+
});
|
|
10050
|
+
const responseBody = truncate2(await response.text());
|
|
10051
|
+
return {
|
|
10052
|
+
attempt: 1,
|
|
10053
|
+
status: response.ok ? "success" : "failed",
|
|
10054
|
+
startedAt,
|
|
10055
|
+
completedAt: now2(),
|
|
10056
|
+
responseStatus: response.status,
|
|
10057
|
+
responseBody,
|
|
10058
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
10059
|
+
};
|
|
10060
|
+
} catch (error) {
|
|
10061
|
+
return {
|
|
10062
|
+
attempt: 1,
|
|
10063
|
+
status: "failed",
|
|
10064
|
+
startedAt,
|
|
10065
|
+
completedAt: now2(),
|
|
10066
|
+
error: error instanceof Error ? error.message : String(error)
|
|
10067
|
+
};
|
|
10068
|
+
} finally {
|
|
10069
|
+
clearTimeout(timeout);
|
|
10070
|
+
}
|
|
10071
|
+
}
|
|
10072
|
+
async function dispatchCommand3(event, channel) {
|
|
10073
|
+
if (!channel.command)
|
|
10074
|
+
throw new Error(`Channel ${channel.id} has no command config`);
|
|
10075
|
+
const startedAt = now2();
|
|
10076
|
+
const eventJson = JSON.stringify(event);
|
|
10077
|
+
const env2 = {
|
|
10078
|
+
...process.env,
|
|
10079
|
+
...channel.command.env,
|
|
10080
|
+
HASNA_CHANNEL_ID: channel.id,
|
|
10081
|
+
HASNA_EVENT_ID: event.id,
|
|
10082
|
+
HASNA_EVENT_TYPE: event.type,
|
|
10083
|
+
HASNA_EVENT_SOURCE: event.source,
|
|
10084
|
+
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
10085
|
+
HASNA_EVENT_SEVERITY: event.severity,
|
|
10086
|
+
HASNA_EVENT_TIME: event.time,
|
|
10087
|
+
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
10088
|
+
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
10089
|
+
HASNA_EVENT_JSON: eventJson
|
|
10090
|
+
};
|
|
10091
|
+
return new Promise((resolve2) => {
|
|
10092
|
+
const child = spawn2(channel.command.command, channel.command.args ?? [], {
|
|
10093
|
+
cwd: channel.command.cwd,
|
|
10094
|
+
env: env2,
|
|
10095
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
10096
|
+
});
|
|
10097
|
+
let stdout = "";
|
|
10098
|
+
let stderr = "";
|
|
10099
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
10100
|
+
child.stdin.end(eventJson);
|
|
10101
|
+
child.stdout.on("data", (chunk) => {
|
|
10102
|
+
stdout += chunk.toString();
|
|
10103
|
+
});
|
|
10104
|
+
child.stderr.on("data", (chunk) => {
|
|
10105
|
+
stderr += chunk.toString();
|
|
10106
|
+
});
|
|
10107
|
+
child.on("error", (error) => {
|
|
10108
|
+
clearTimeout(timeout);
|
|
10109
|
+
resolve2({
|
|
10110
|
+
attempt: 1,
|
|
10111
|
+
status: "failed",
|
|
10112
|
+
startedAt,
|
|
10113
|
+
completedAt: now2(),
|
|
10114
|
+
stdout: truncate2(stdout),
|
|
10115
|
+
stderr: truncate2(stderr),
|
|
10116
|
+
error: error.message
|
|
10117
|
+
});
|
|
10118
|
+
});
|
|
10119
|
+
child.on("close", (code, signal) => {
|
|
10120
|
+
clearTimeout(timeout);
|
|
10121
|
+
const success = code === 0;
|
|
10122
|
+
resolve2({
|
|
10123
|
+
attempt: 1,
|
|
10124
|
+
status: success ? "success" : "failed",
|
|
10125
|
+
startedAt,
|
|
10126
|
+
completedAt: now2(),
|
|
10127
|
+
stdout: truncate2(stdout),
|
|
10128
|
+
stderr: truncate2(stderr),
|
|
10129
|
+
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
10130
|
+
});
|
|
10131
|
+
});
|
|
10132
|
+
});
|
|
10133
|
+
}
|
|
10134
|
+
async function dispatchChannel3(event, channel, options = {}) {
|
|
10135
|
+
if (channel.transport === "webhook")
|
|
10136
|
+
return dispatchWebhook3(event, channel, options);
|
|
10137
|
+
if (channel.transport === "command")
|
|
10138
|
+
return dispatchCommand3(event, channel);
|
|
10139
|
+
return {
|
|
10140
|
+
attempt: 1,
|
|
10141
|
+
status: "skipped",
|
|
10142
|
+
startedAt: now2(),
|
|
10143
|
+
completedAt: now2(),
|
|
10144
|
+
error: `Unsupported transport: ${channel.transport}`
|
|
10145
|
+
};
|
|
10146
|
+
}
|
|
10147
|
+
function createDeliveryResult2(event, channel, attempts) {
|
|
10148
|
+
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
10149
|
+
return {
|
|
10150
|
+
id: randomUUID3(),
|
|
10151
|
+
eventId: event.id,
|
|
10152
|
+
channelId: channel.id,
|
|
10153
|
+
transport: channel.transport,
|
|
10154
|
+
status,
|
|
10155
|
+
attempts,
|
|
10156
|
+
createdAt: attempts[0]?.startedAt ?? now2(),
|
|
10157
|
+
completedAt: attempts.at(-1)?.completedAt ?? now2()
|
|
10158
|
+
};
|
|
10159
|
+
}
|
|
10160
|
+
function createEvent2(input) {
|
|
10161
|
+
return {
|
|
10162
|
+
id: input.id ?? randomUUID22(),
|
|
10163
|
+
source: input.source,
|
|
10164
|
+
type: input.type,
|
|
10165
|
+
time: normalizeTime2(input.time),
|
|
10166
|
+
subject: input.subject,
|
|
10167
|
+
severity: input.severity ?? "info",
|
|
10168
|
+
data: input.data ?? {},
|
|
10169
|
+
message: input.message,
|
|
10170
|
+
dedupeKey: input.dedupeKey,
|
|
10171
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
10172
|
+
metadata: input.metadata ?? {}
|
|
10173
|
+
};
|
|
10174
|
+
}
|
|
10175
|
+
|
|
10176
|
+
class EventsClient2 {
|
|
10177
|
+
store;
|
|
10178
|
+
redactors;
|
|
10179
|
+
transportOptions;
|
|
10180
|
+
constructor(options = {}) {
|
|
10181
|
+
this.store = options.store ?? new JsonEventsStore2(options.dataDir);
|
|
10182
|
+
this.redactors = options.redactors ?? [];
|
|
10183
|
+
this.transportOptions = { fetchImpl: options.fetchImpl };
|
|
10184
|
+
}
|
|
10185
|
+
async addChannel(input) {
|
|
10186
|
+
const timestamp = new Date().toISOString();
|
|
10187
|
+
return this.store.addChannel({
|
|
10188
|
+
...input,
|
|
10189
|
+
createdAt: input.createdAt ?? timestamp,
|
|
10190
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
10191
|
+
});
|
|
10192
|
+
}
|
|
10193
|
+
async listChannels() {
|
|
10194
|
+
return this.store.listChannels();
|
|
10195
|
+
}
|
|
10196
|
+
async removeChannel(id) {
|
|
10197
|
+
return this.store.removeChannel(id);
|
|
10198
|
+
}
|
|
10199
|
+
async emit(input, options = {}) {
|
|
10200
|
+
const event = options.redactSensitiveData === false ? createEvent2(input) : redactSensitiveKeys2(createEvent2(input));
|
|
10201
|
+
if (options.dedupe !== false) {
|
|
10202
|
+
const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
|
|
10203
|
+
if (existing) {
|
|
10204
|
+
return { event: existing, deliveries: [], deduped: true };
|
|
10205
|
+
}
|
|
10206
|
+
}
|
|
10207
|
+
await this.store.appendEvent(event);
|
|
10208
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
10209
|
+
return { event, deliveries, deduped: false };
|
|
10210
|
+
}
|
|
10211
|
+
async listEvents() {
|
|
10212
|
+
return this.store.listEvents();
|
|
10213
|
+
}
|
|
10214
|
+
async listDeliveries() {
|
|
10215
|
+
return this.store.listDeliveries();
|
|
10216
|
+
}
|
|
10217
|
+
async deliver(event) {
|
|
10218
|
+
const channels = await this.store.listChannels();
|
|
10219
|
+
const selected = channels.filter((channel) => channelMatchesEvent2(channel, event));
|
|
10220
|
+
const deliveries = [];
|
|
10221
|
+
for (const channel of selected) {
|
|
10222
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
10223
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
10224
|
+
await this.store.appendDelivery(result);
|
|
10225
|
+
deliveries.push(result);
|
|
10226
|
+
}
|
|
10227
|
+
return deliveries;
|
|
10228
|
+
}
|
|
10229
|
+
async testChannel(id, input = {}) {
|
|
10230
|
+
const channel = await this.store.getChannel(id);
|
|
10231
|
+
if (!channel)
|
|
10232
|
+
throw new Error(`Channel not found: ${id}`);
|
|
10233
|
+
const event = createEvent2({
|
|
10234
|
+
source: input.source ?? "hasna.events",
|
|
10235
|
+
type: input.type ?? "events.test",
|
|
10236
|
+
subject: input.subject ?? id,
|
|
10237
|
+
severity: input.severity ?? "info",
|
|
10238
|
+
data: input.data ?? { test: true },
|
|
10239
|
+
message: input.message ?? "Hasna events test delivery",
|
|
10240
|
+
dedupeKey: input.dedupeKey,
|
|
10241
|
+
schemaVersion: input.schemaVersion,
|
|
10242
|
+
metadata: input.metadata,
|
|
10243
|
+
time: input.time,
|
|
10244
|
+
id: input.id
|
|
10245
|
+
});
|
|
10246
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
10247
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
10248
|
+
await this.store.appendDelivery(result);
|
|
10249
|
+
return result;
|
|
10250
|
+
}
|
|
10251
|
+
async replay(options = {}) {
|
|
10252
|
+
const events = (await this.store.listEvents()).filter((event) => {
|
|
10253
|
+
if (options.eventId && event.id !== options.eventId)
|
|
10254
|
+
return false;
|
|
10255
|
+
if (options.source && event.source !== options.source)
|
|
10256
|
+
return false;
|
|
10257
|
+
if (options.type && event.type !== options.type)
|
|
10258
|
+
return false;
|
|
10259
|
+
return true;
|
|
10260
|
+
});
|
|
10261
|
+
if (options.dryRun)
|
|
10262
|
+
return { events, deliveries: [] };
|
|
10263
|
+
const deliveries = [];
|
|
10264
|
+
for (const event of events) {
|
|
10265
|
+
deliveries.push(...await this.deliver(event));
|
|
10266
|
+
}
|
|
10267
|
+
return { events, deliveries };
|
|
10268
|
+
}
|
|
10269
|
+
async applyRedaction(event, channel) {
|
|
10270
|
+
let next = redactPaths2(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
10271
|
+
for (const redactor of this.redactors) {
|
|
10272
|
+
next = await redactor(next, channel);
|
|
10273
|
+
}
|
|
10274
|
+
return next;
|
|
10275
|
+
}
|
|
10276
|
+
async deliverWithRetry(event, channel) {
|
|
10277
|
+
const policy = normalizeRetryPolicy2(channel.retry);
|
|
10278
|
+
const attempts = [];
|
|
10279
|
+
for (let index = 0;index < policy.maxAttempts; index += 1) {
|
|
10280
|
+
const attempt = await dispatchChannel3(event, channel, this.transportOptions);
|
|
10281
|
+
attempt.attempt = index + 1;
|
|
10282
|
+
if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
|
|
10283
|
+
attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
|
|
10284
|
+
}
|
|
10285
|
+
attempts.push(attempt);
|
|
10286
|
+
if (attempt.status !== "failed")
|
|
10287
|
+
break;
|
|
10288
|
+
if (attempt.nextBackoffMs)
|
|
10289
|
+
await Bun.sleep(attempt.nextBackoffMs);
|
|
10290
|
+
}
|
|
10291
|
+
return createDeliveryResult2(event, channel, attempts);
|
|
10292
|
+
}
|
|
10293
|
+
}
|
|
10294
|
+
function redactPaths2(event, paths, replacement = "[REDACTED]") {
|
|
10295
|
+
if (paths.length === 0)
|
|
10296
|
+
return event;
|
|
10297
|
+
const copy = structuredClone(event);
|
|
10298
|
+
for (const path of paths) {
|
|
10299
|
+
setPath2(copy, path, replacement);
|
|
10300
|
+
}
|
|
10301
|
+
return copy;
|
|
10302
|
+
}
|
|
10303
|
+
function sanitizeChannelForOutput2(channel) {
|
|
10304
|
+
const copy = structuredClone(channel);
|
|
10305
|
+
if (copy.webhook?.secret)
|
|
10306
|
+
copy.webhook.secret = "[REDACTED]";
|
|
10307
|
+
if (copy.command?.env) {
|
|
10308
|
+
copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey2(key) ? "[REDACTED]" : value]));
|
|
10309
|
+
}
|
|
10310
|
+
return copy;
|
|
10311
|
+
}
|
|
10312
|
+
function sanitizeChannelsForOutput2(channels) {
|
|
10313
|
+
return channels.map(sanitizeChannelForOutput2);
|
|
10314
|
+
}
|
|
10315
|
+
function redactSensitiveKeys2(event, replacement = "[REDACTED]") {
|
|
10316
|
+
return redactValue2(event, replacement);
|
|
10317
|
+
}
|
|
10318
|
+
function shouldRedactKey2(key) {
|
|
10319
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
10320
|
+
}
|
|
10321
|
+
function redactValue2(value, replacement) {
|
|
10322
|
+
if (Array.isArray(value))
|
|
10323
|
+
return value.map((item) => redactValue2(item, replacement));
|
|
10324
|
+
if (!value || typeof value !== "object")
|
|
10325
|
+
return value;
|
|
10326
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
10327
|
+
key,
|
|
10328
|
+
shouldRedactKey2(key) ? replacement : redactValue2(item, replacement)
|
|
10329
|
+
]));
|
|
10330
|
+
}
|
|
10331
|
+
function setPath2(input, path, replacement) {
|
|
10332
|
+
const parts = path.split(".");
|
|
10333
|
+
let cursor = input;
|
|
10334
|
+
for (const part of parts.slice(0, -1)) {
|
|
10335
|
+
const next = cursor[part];
|
|
10336
|
+
if (!next || typeof next !== "object")
|
|
10337
|
+
return;
|
|
10338
|
+
cursor = next;
|
|
10339
|
+
}
|
|
10340
|
+
const last = parts.at(-1);
|
|
10341
|
+
if (last && last in cursor)
|
|
10342
|
+
cursor[last] = replacement;
|
|
10343
|
+
}
|
|
10344
|
+
function normalizeTime2(value) {
|
|
10345
|
+
if (!value)
|
|
10346
|
+
return new Date().toISOString();
|
|
10347
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
10348
|
+
}
|
|
10349
|
+
function normalizeRetryPolicy2(policy) {
|
|
10350
|
+
return {
|
|
10351
|
+
maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
|
|
10352
|
+
backoffMs: Math.max(0, policy?.backoffMs ?? 250),
|
|
10353
|
+
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
10354
|
+
};
|
|
10355
|
+
}
|
|
10356
|
+
|
|
10357
|
+
// src/commands/runtime.ts
|
|
10358
|
+
function probeTmuxPane(target, tmuxCommand = process.env["HASNA_MACHINES_TMUX_BIN"] || "tmux") {
|
|
10359
|
+
const checkedAt = new Date().toISOString();
|
|
10360
|
+
const result = spawnSync4(tmuxCommand, ["display-message", "-p", "-t", target, "#{pane_id}"], {
|
|
10361
|
+
encoding: "utf8",
|
|
10362
|
+
timeout: 5000
|
|
10363
|
+
});
|
|
10364
|
+
const stdout = result.stdout?.trim();
|
|
10365
|
+
const stderr = result.stderr?.trim();
|
|
10366
|
+
const exists = result.status === 0 && Boolean(stdout);
|
|
10367
|
+
return {
|
|
10368
|
+
target,
|
|
10369
|
+
exists,
|
|
10370
|
+
paneId: exists ? stdout : undefined,
|
|
10371
|
+
checkedAt,
|
|
10372
|
+
exitCode: result.status,
|
|
10373
|
+
error: result.error?.message,
|
|
10374
|
+
stderr: stderr || undefined
|
|
10375
|
+
};
|
|
10376
|
+
}
|
|
10377
|
+
async function watchTmuxPane(options) {
|
|
10378
|
+
const target = options.target.trim();
|
|
10379
|
+
if (!target)
|
|
10380
|
+
throw new Error("tmux pane target is required");
|
|
10381
|
+
const intervalMs = Math.max(0, options.intervalMs ?? 5000);
|
|
10382
|
+
const maxChecks = options.maxChecks ?? Number.POSITIVE_INFINITY;
|
|
10383
|
+
const client = options.client ?? new EventsClient2;
|
|
10384
|
+
const probe = options.probe ?? ((paneTarget) => probeTmuxPane(paneTarget, options.tmuxCommand));
|
|
10385
|
+
const wait = options.sleep ?? sleep;
|
|
10386
|
+
let lastPresent;
|
|
10387
|
+
let lastProbe;
|
|
10388
|
+
for (let checks = 1;checks <= maxChecks; checks += 1) {
|
|
10389
|
+
const current = await probe(target);
|
|
10390
|
+
lastProbe = current;
|
|
10391
|
+
options.onProbe?.(current);
|
|
10392
|
+
if (current.exists) {
|
|
10393
|
+
lastPresent = current;
|
|
10394
|
+
} else if (lastPresent) {
|
|
10395
|
+
const emitted = await emitTmuxEvent(client, "machines.tmux.pane_died", current, lastPresent, options.deliver !== false);
|
|
10396
|
+
return { target, checks, status: "died", lastProbe: current, emitted };
|
|
10397
|
+
} else if (options.emitInitialMissing) {
|
|
10398
|
+
const emitted = await emitTmuxEvent(client, "machines.tmux.pane_missing", current, undefined, options.deliver !== false);
|
|
10399
|
+
return { target, checks, status: "missing", lastProbe: current, emitted };
|
|
10400
|
+
}
|
|
10401
|
+
if (checks < maxChecks)
|
|
10402
|
+
await wait(intervalMs);
|
|
10403
|
+
}
|
|
10404
|
+
if (!lastProbe) {
|
|
10405
|
+
lastProbe = {
|
|
10406
|
+
target,
|
|
10407
|
+
exists: false,
|
|
10408
|
+
checkedAt: new Date().toISOString(),
|
|
10409
|
+
error: "No probe executed"
|
|
10410
|
+
};
|
|
10411
|
+
}
|
|
10412
|
+
return {
|
|
10413
|
+
target,
|
|
10414
|
+
checks: Number.isFinite(maxChecks) ? maxChecks : 0,
|
|
10415
|
+
status: lastProbe.exists ? "present" : "stopped",
|
|
10416
|
+
lastProbe
|
|
10417
|
+
};
|
|
10418
|
+
}
|
|
10419
|
+
async function emitTmuxEvent(client, type, probe, lastPresent, deliver) {
|
|
10420
|
+
const input = {
|
|
10421
|
+
source: "machines",
|
|
10422
|
+
type,
|
|
10423
|
+
subject: `tmux:${probe.target}`,
|
|
10424
|
+
severity: type === "machines.tmux.pane_died" ? "warning" : "notice",
|
|
10425
|
+
message: type === "machines.tmux.pane_died" ? `tmux pane disappeared: ${probe.target}` : `tmux pane is missing: ${probe.target}`,
|
|
10426
|
+
data: {
|
|
10427
|
+
target: probe.target,
|
|
10428
|
+
paneId: lastPresent?.paneId,
|
|
10429
|
+
lastSeenAt: lastPresent?.checkedAt,
|
|
10430
|
+
checkedAt: probe.checkedAt,
|
|
10431
|
+
exitCode: probe.exitCode,
|
|
10432
|
+
error: probe.error,
|
|
10433
|
+
stderr: probe.stderr
|
|
10434
|
+
},
|
|
10435
|
+
metadata: {
|
|
10436
|
+
monitor: "tmux-pane",
|
|
10437
|
+
runtime: "machines"
|
|
10438
|
+
}
|
|
10439
|
+
};
|
|
10440
|
+
return client.emit(input, { deliver });
|
|
10441
|
+
}
|
|
10442
|
+
|
|
9155
10443
|
// src/commands/screen.ts
|
|
9156
10444
|
var DEFAULT_SCREEN_SECRET_NAMESPACE = "hasna/xyz/opensource/machines/prod";
|
|
9157
10445
|
function shellQuote6(value) {
|
|
@@ -9261,8 +10549,8 @@ function buildScreenEnableCommand(machineId, options = {}) {
|
|
|
9261
10549
|
}
|
|
9262
10550
|
|
|
9263
10551
|
// src/commands/sync.ts
|
|
9264
|
-
import { existsSync as
|
|
9265
|
-
import { homedir as
|
|
10552
|
+
import { existsSync as existsSync9, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
|
|
10553
|
+
import { homedir as homedir7 } from "os";
|
|
9266
10554
|
init_paths();
|
|
9267
10555
|
init_db();
|
|
9268
10556
|
function quote4(value) {
|
|
@@ -9312,8 +10600,8 @@ function detectPackageActions(machine) {
|
|
|
9312
10600
|
}
|
|
9313
10601
|
function detectFileActions(machine) {
|
|
9314
10602
|
return (machine.files || []).map((file, index) => {
|
|
9315
|
-
const sourceExists =
|
|
9316
|
-
const targetExists =
|
|
10603
|
+
const sourceExists = existsSync9(file.source);
|
|
10604
|
+
const targetExists = existsSync9(file.target);
|
|
9317
10605
|
let status = "missing";
|
|
9318
10606
|
if (sourceExists && targetExists) {
|
|
9319
10607
|
if (file.mode === "symlink") {
|
|
@@ -9341,7 +10629,7 @@ function buildSyncPlan(machineId) {
|
|
|
9341
10629
|
const target = selected || {
|
|
9342
10630
|
id: currentMachineId,
|
|
9343
10631
|
platform: "linux",
|
|
9344
|
-
workspacePath: `${
|
|
10632
|
+
workspacePath: `${homedir7()}/workspace`
|
|
9345
10633
|
};
|
|
9346
10634
|
const actions = [
|
|
9347
10635
|
...detectPackageActions(target),
|
|
@@ -9915,13 +11203,16 @@ function getServeInfo(options = {}) {
|
|
|
9915
11203
|
"/api/status",
|
|
9916
11204
|
"/api/manifest",
|
|
9917
11205
|
"/api/notifications",
|
|
11206
|
+
"/api/webhooks",
|
|
11207
|
+
"/api/events",
|
|
9918
11208
|
"/api/doctor",
|
|
9919
11209
|
"/api/self-test",
|
|
9920
11210
|
"/api/apps/status",
|
|
9921
11211
|
"/api/apps/diff",
|
|
9922
11212
|
"/api/install-claude/status",
|
|
9923
11213
|
"/api/install-claude/diff",
|
|
9924
|
-
"/api/notifications/test"
|
|
11214
|
+
"/api/notifications/test",
|
|
11215
|
+
"/api/webhooks/test"
|
|
9925
11216
|
]
|
|
9926
11217
|
};
|
|
9927
11218
|
}
|
|
@@ -10088,6 +11379,7 @@ function jsonError(message, status = 400) {
|
|
|
10088
11379
|
}
|
|
10089
11380
|
function startDashboardServer(options = {}) {
|
|
10090
11381
|
const info = getServeInfo(options);
|
|
11382
|
+
const events = new EventsClient2;
|
|
10091
11383
|
return Bun.serve({
|
|
10092
11384
|
hostname: info.host,
|
|
10093
11385
|
port: info.port,
|
|
@@ -10107,6 +11399,42 @@ function startDashboardServer(options = {}) {
|
|
|
10107
11399
|
if (url.pathname === "/api/notifications") {
|
|
10108
11400
|
return Response.json(listNotificationChannels());
|
|
10109
11401
|
}
|
|
11402
|
+
if (url.pathname === "/api/webhooks") {
|
|
11403
|
+
if (request.method !== "GET") {
|
|
11404
|
+
return jsonError("Use GET for webhook channel listing.", 405);
|
|
11405
|
+
}
|
|
11406
|
+
return Response.json(sanitizeChannelsForOutput2(await events.listChannels()));
|
|
11407
|
+
}
|
|
11408
|
+
if (url.pathname === "/api/events") {
|
|
11409
|
+
if (request.method === "GET") {
|
|
11410
|
+
return Response.json(await events.listEvents());
|
|
11411
|
+
}
|
|
11412
|
+
if (request.method !== "POST") {
|
|
11413
|
+
return jsonError("Use GET or POST for events.", 405);
|
|
11414
|
+
}
|
|
11415
|
+
const body = await parseJsonBody(request);
|
|
11416
|
+
const type = typeof body["type"] === "string" ? body["type"] : undefined;
|
|
11417
|
+
if (!type) {
|
|
11418
|
+
return jsonError("type is required.");
|
|
11419
|
+
}
|
|
11420
|
+
const source = typeof body["source"] === "string" ? body["source"] : "machines";
|
|
11421
|
+
const subject = typeof body["subject"] === "string" ? body["subject"] : undefined;
|
|
11422
|
+
const severity = typeof body["severity"] === "string" ? body["severity"] : undefined;
|
|
11423
|
+
const message = typeof body["message"] === "string" ? body["message"] : undefined;
|
|
11424
|
+
const dedupeKey = typeof body["dedupeKey"] === "string" ? body["dedupeKey"] : undefined;
|
|
11425
|
+
const data = body["data"] && typeof body["data"] === "object" && !Array.isArray(body["data"]) ? body["data"] : {};
|
|
11426
|
+
const metadata = body["metadata"] && typeof body["metadata"] === "object" && !Array.isArray(body["metadata"]) ? body["metadata"] : {};
|
|
11427
|
+
return Response.json(await events.emit({
|
|
11428
|
+
source,
|
|
11429
|
+
type,
|
|
11430
|
+
subject,
|
|
11431
|
+
severity,
|
|
11432
|
+
message,
|
|
11433
|
+
dedupeKey,
|
|
11434
|
+
data,
|
|
11435
|
+
metadata
|
|
11436
|
+
}));
|
|
11437
|
+
}
|
|
10110
11438
|
if (url.pathname === "/api/doctor") {
|
|
10111
11439
|
return Response.json(runDoctor(machineId));
|
|
10112
11440
|
}
|
|
@@ -10144,6 +11472,28 @@ function startDashboardServer(options = {}) {
|
|
|
10144
11472
|
return jsonError(error instanceof Error ? error.message : String(error));
|
|
10145
11473
|
}
|
|
10146
11474
|
}
|
|
11475
|
+
if (url.pathname === "/api/webhooks/test") {
|
|
11476
|
+
if (request.method !== "POST") {
|
|
11477
|
+
return jsonError("Use POST for webhook tests.", 405);
|
|
11478
|
+
}
|
|
11479
|
+
const body = await parseJsonBody(request);
|
|
11480
|
+
const channelId = typeof body["channelId"] === "string" ? body["channelId"] : undefined;
|
|
11481
|
+
if (!channelId) {
|
|
11482
|
+
return jsonError("channelId is required.");
|
|
11483
|
+
}
|
|
11484
|
+
const type = typeof body["type"] === "string" ? body["type"] : "events.test";
|
|
11485
|
+
const message = typeof body["message"] === "string" ? body["message"] : undefined;
|
|
11486
|
+
try {
|
|
11487
|
+
return Response.json(await events.testChannel(channelId, {
|
|
11488
|
+
source: "machines",
|
|
11489
|
+
type,
|
|
11490
|
+
subject: channelId,
|
|
11491
|
+
message
|
|
11492
|
+
}));
|
|
11493
|
+
} catch (error) {
|
|
11494
|
+
return jsonError(error instanceof Error ? error.message : String(error));
|
|
11495
|
+
}
|
|
11496
|
+
}
|
|
10147
11497
|
return new Response(renderDashboardHtml(), {
|
|
10148
11498
|
headers: {
|
|
10149
11499
|
"content-type": "text/html; charset=utf-8"
|
|
@@ -10186,8 +11536,8 @@ function runSelfTest() {
|
|
|
10186
11536
|
// src/commands/clipboard.ts
|
|
10187
11537
|
init_paths();
|
|
10188
11538
|
import { createHash } from "crypto";
|
|
10189
|
-
import { existsSync as
|
|
10190
|
-
import { join as
|
|
11539
|
+
import { existsSync as existsSync10, readFileSync as readFileSync6, rmSync, writeFileSync as writeFileSync4 } from "fs";
|
|
11540
|
+
import { join as join8 } from "path";
|
|
10191
11541
|
var DEFAULT_CONFIG = {
|
|
10192
11542
|
version: 1,
|
|
10193
11543
|
enabled: true,
|
|
@@ -10205,7 +11555,7 @@ var DEFAULT_CONFIG = {
|
|
|
10205
11555
|
function resolveConfigPath(configPath) {
|
|
10206
11556
|
if (configPath)
|
|
10207
11557
|
return configPath;
|
|
10208
|
-
return
|
|
11558
|
+
return join8(getDataDir(), "clipboard-config.json");
|
|
10209
11559
|
}
|
|
10210
11560
|
function resolveHistoryPath(historyPath) {
|
|
10211
11561
|
if (historyPath)
|
|
@@ -10217,7 +11567,7 @@ function getDefaultConfig() {
|
|
|
10217
11567
|
}
|
|
10218
11568
|
function readConfig(configPath) {
|
|
10219
11569
|
const path = resolveConfigPath(configPath);
|
|
10220
|
-
if (!
|
|
11570
|
+
if (!existsSync10(path)) {
|
|
10221
11571
|
return getDefaultConfig();
|
|
10222
11572
|
}
|
|
10223
11573
|
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
@@ -10231,7 +11581,7 @@ function writeConfig(config, configPath) {
|
|
|
10231
11581
|
}
|
|
10232
11582
|
function readHistory(historyPath) {
|
|
10233
11583
|
const path = resolveHistoryPath(historyPath);
|
|
10234
|
-
if (!
|
|
11584
|
+
if (!existsSync10(path)) {
|
|
10235
11585
|
return [];
|
|
10236
11586
|
}
|
|
10237
11587
|
try {
|
|
@@ -10264,7 +11614,7 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
|
|
|
10264
11614
|
}
|
|
10265
11615
|
function getOrCreateClipboardKey() {
|
|
10266
11616
|
const keyPath = getClipboardKeyPath();
|
|
10267
|
-
if (
|
|
11617
|
+
if (existsSync10(keyPath)) {
|
|
10268
11618
|
return readFileSync6(keyPath, "utf8").trim();
|
|
10269
11619
|
}
|
|
10270
11620
|
const key = createHash("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
|
|
@@ -10304,7 +11654,7 @@ function addClipboardEntry(entry, historyPath) {
|
|
|
10304
11654
|
}
|
|
10305
11655
|
function clearClipboardHistory(historyPath) {
|
|
10306
11656
|
const path = resolveHistoryPath(historyPath);
|
|
10307
|
-
if (
|
|
11657
|
+
if (existsSync10(path)) {
|
|
10308
11658
|
rmSync(path);
|
|
10309
11659
|
}
|
|
10310
11660
|
}
|
|
@@ -10321,7 +11671,7 @@ function getClipboardStatus(historyPath) {
|
|
|
10321
11671
|
// src/commands/clipboard-daemon.ts
|
|
10322
11672
|
init_paths();
|
|
10323
11673
|
import { readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
10324
|
-
import { join as
|
|
11674
|
+
import { join as join9 } from "path";
|
|
10325
11675
|
import { createHash as createHash3 } from "crypto";
|
|
10326
11676
|
|
|
10327
11677
|
// src/commands/clipboard-server.ts
|
|
@@ -10479,7 +11829,7 @@ function handleGetClipboard(response, config) {
|
|
|
10479
11829
|
}
|
|
10480
11830
|
|
|
10481
11831
|
// src/commands/clipboard-daemon.ts
|
|
10482
|
-
var DAEMON_PID_PATH =
|
|
11832
|
+
var DAEMON_PID_PATH = join9(getDataDir(), "clipboard-daemon.pid");
|
|
10483
11833
|
function readLocalClipboardSync2() {
|
|
10484
11834
|
const platform4 = process.platform;
|
|
10485
11835
|
if (platform4 === "darwin") {
|
|
@@ -10653,8 +12003,8 @@ async function discoverPeers() {
|
|
|
10653
12003
|
|
|
10654
12004
|
// src/commands/heal.ts
|
|
10655
12005
|
init_paths();
|
|
10656
|
-
import { existsSync as
|
|
10657
|
-
import { join as
|
|
12006
|
+
import { existsSync as existsSync11, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
12007
|
+
import { join as join10 } from "path";
|
|
10658
12008
|
var DEFAULT_THRESHOLDS = {
|
|
10659
12009
|
reconnect: 3,
|
|
10660
12010
|
nmRestart: 7,
|
|
@@ -10698,14 +12048,14 @@ function defaultHealState() {
|
|
|
10698
12048
|
};
|
|
10699
12049
|
}
|
|
10700
12050
|
function getHealConfigPath() {
|
|
10701
|
-
return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] ||
|
|
12051
|
+
return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join10(getDataDir(), "heal-config.json");
|
|
10702
12052
|
}
|
|
10703
12053
|
function getHealStatePath() {
|
|
10704
|
-
return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] ||
|
|
12054
|
+
return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join10(getDataDir(), "heal-state.json");
|
|
10705
12055
|
}
|
|
10706
12056
|
function readHealConfig(path) {
|
|
10707
12057
|
const p = path || getHealConfigPath();
|
|
10708
|
-
if (!
|
|
12058
|
+
if (!existsSync11(p))
|
|
10709
12059
|
return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
|
|
10710
12060
|
const parsed = JSON.parse(readFileSync9(p, "utf8"));
|
|
10711
12061
|
return {
|
|
@@ -10723,7 +12073,7 @@ function writeHealConfig(config, path) {
|
|
|
10723
12073
|
}
|
|
10724
12074
|
function readHealState(path) {
|
|
10725
12075
|
const p = path || getHealStatePath();
|
|
10726
|
-
if (!
|
|
12076
|
+
if (!existsSync11(p))
|
|
10727
12077
|
return defaultHealState();
|
|
10728
12078
|
try {
|
|
10729
12079
|
return { ...defaultHealState(), ...JSON.parse(readFileSync9(p, "utf8")) };
|
|
@@ -10763,7 +12113,7 @@ function evaluateHealth(probe, config, state) {
|
|
|
10763
12113
|
return { healthy: localOk && quorumOk, remoteScore, reasons };
|
|
10764
12114
|
}
|
|
10765
12115
|
function decideAction(input) {
|
|
10766
|
-
const { healthy, now, gpuBusy, config, currentBootId } = input;
|
|
12116
|
+
const { healthy, now: now3, gpuBusy, config, currentBootId } = input;
|
|
10767
12117
|
const s = { ...input.state };
|
|
10768
12118
|
const t = config.thresholds;
|
|
10769
12119
|
if (s.bootId !== currentBootId) {
|
|
@@ -10774,13 +12124,13 @@ function decideAction(input) {
|
|
|
10774
12124
|
if (healthy) {
|
|
10775
12125
|
s.failCount = 0;
|
|
10776
12126
|
if (s.bootHealthySince === null)
|
|
10777
|
-
s.bootHealthySince =
|
|
10778
|
-
if (
|
|
12127
|
+
s.bootHealthySince = now3;
|
|
12128
|
+
if (now3 - s.bootHealthySince >= config.healthyWindowSec) {
|
|
10779
12129
|
s.failedBootRecoveries = 0;
|
|
10780
12130
|
s.rebootSuppressUntil = 0;
|
|
10781
12131
|
s.pendingRebootRecovery = false;
|
|
10782
12132
|
}
|
|
10783
|
-
if (s.degradedUntil > 0 &&
|
|
12133
|
+
if (s.degradedUntil > 0 && now3 >= s.degradedUntil) {
|
|
10784
12134
|
s.degradedUntil = 0;
|
|
10785
12135
|
return { action: "restore_preferred", state: s };
|
|
10786
12136
|
}
|
|
@@ -10798,8 +12148,8 @@ function decideAction(input) {
|
|
|
10798
12148
|
else if (s.failCount >= t.reconnect)
|
|
10799
12149
|
tier = "reconnect";
|
|
10800
12150
|
const tryReconnect = (reason) => {
|
|
10801
|
-
if (
|
|
10802
|
-
s.lastReconnect =
|
|
12151
|
+
if (now3 - s.lastReconnect >= config.reconnectMinIntervalSec) {
|
|
12152
|
+
s.lastReconnect = now3;
|
|
10803
12153
|
return { action: "reconnect_wifi", suppressedReason: reason, state: s };
|
|
10804
12154
|
}
|
|
10805
12155
|
return { action: "none", suppressedReason: reason, state: s };
|
|
@@ -10808,15 +12158,15 @@ function decideAction(input) {
|
|
|
10808
12158
|
case "reconnect":
|
|
10809
12159
|
return tryReconnect();
|
|
10810
12160
|
case "nmRestart":
|
|
10811
|
-
if (
|
|
10812
|
-
s.lastNmRestart =
|
|
12161
|
+
if (now3 - s.lastNmRestart >= config.nmRestartMinIntervalSec) {
|
|
12162
|
+
s.lastNmRestart = now3;
|
|
10813
12163
|
return { action: "restart_nm", state: s };
|
|
10814
12164
|
}
|
|
10815
12165
|
return tryReconnect();
|
|
10816
12166
|
case "fallback":
|
|
10817
|
-
if (
|
|
10818
|
-
s.lastFallback =
|
|
10819
|
-
s.degradedUntil =
|
|
12167
|
+
if (now3 - s.lastFallback >= config.fallbackWindowSec) {
|
|
12168
|
+
s.lastFallback = now3;
|
|
12169
|
+
s.degradedUntil = now3 + config.fallbackWindowSec;
|
|
10820
12170
|
return { action: "fallback_ssid", state: s };
|
|
10821
12171
|
}
|
|
10822
12172
|
return tryReconnect();
|
|
@@ -10824,22 +12174,22 @@ function decideAction(input) {
|
|
|
10824
12174
|
let reason = null;
|
|
10825
12175
|
if (!config.allowReboot)
|
|
10826
12176
|
reason = "disabled";
|
|
10827
|
-
else if (
|
|
12177
|
+
else if (now3 < s.rebootSuppressUntil)
|
|
10828
12178
|
reason = "loop";
|
|
10829
12179
|
else if (config.gpuJobGuard && gpuBusy)
|
|
10830
12180
|
reason = "gpu";
|
|
10831
|
-
else if (
|
|
12181
|
+
else if (now3 - s.lastRebootAttempt < config.rebootMinIntervalSec)
|
|
10832
12182
|
reason = "rate";
|
|
10833
12183
|
if (reason)
|
|
10834
12184
|
return tryReconnect(reason);
|
|
10835
12185
|
if (s.pendingRebootRecovery) {
|
|
10836
12186
|
s.failedBootRecoveries += 1;
|
|
10837
12187
|
if (s.failedBootRecoveries >= config.maxFailedBootRecoveries) {
|
|
10838
|
-
s.rebootSuppressUntil =
|
|
12188
|
+
s.rebootSuppressUntil = now3 + config.bootBackoffSec;
|
|
10839
12189
|
return tryReconnect("loop");
|
|
10840
12190
|
}
|
|
10841
12191
|
}
|
|
10842
|
-
s.lastRebootAttempt =
|
|
12192
|
+
s.lastRebootAttempt = now3;
|
|
10843
12193
|
s.pendingRebootRecovery = true;
|
|
10844
12194
|
return { action: "reboot", state: s };
|
|
10845
12195
|
}
|
|
@@ -10939,9 +12289,9 @@ function executeAction(action, config) {
|
|
|
10939
12289
|
|
|
10940
12290
|
// src/commands/heal-daemon.ts
|
|
10941
12291
|
init_paths();
|
|
10942
|
-
import { existsSync as
|
|
10943
|
-
import { join as
|
|
10944
|
-
var DAEMON_PID_PATH2 =
|
|
12292
|
+
import { existsSync as existsSync12, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
|
|
12293
|
+
import { join as join11 } from "path";
|
|
12294
|
+
var DAEMON_PID_PATH2 = join11(getDataDir(), "heal-daemon.pid");
|
|
10945
12295
|
var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
|
|
10946
12296
|
var SYSTEM_CONF = "/etc/systemd/system.conf";
|
|
10947
12297
|
function log(msg) {
|
|
@@ -11059,7 +12409,7 @@ function applyDeterminism(config) {
|
|
|
11059
12409
|
}
|
|
11060
12410
|
function enableHardwareWatchdog() {
|
|
11061
12411
|
const log2 = [];
|
|
11062
|
-
if (!
|
|
12412
|
+
if (!existsSync12(SYSTEM_CONF))
|
|
11063
12413
|
return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
|
|
11064
12414
|
let conf = readFileSync10(SYSTEM_CONF, "utf8");
|
|
11065
12415
|
const set = (key, value) => {
|
|
@@ -11089,7 +12439,7 @@ function binPath() {
|
|
|
11089
12439
|
const home = process.env["HOME"] || "/home/hasna";
|
|
11090
12440
|
candidates.push(`${home}/.bun/bin/machines`, "/home/hasna/.bun/bin/machines", "/root/.bun/bin/machines", "/usr/local/bin/machines");
|
|
11091
12441
|
for (const c of candidates) {
|
|
11092
|
-
if (c &&
|
|
12442
|
+
if (c && existsSync12(c))
|
|
11093
12443
|
return c;
|
|
11094
12444
|
}
|
|
11095
12445
|
return "machines";
|
|
@@ -11125,7 +12475,7 @@ WantedBy=multi-user.target
|
|
|
11125
12475
|
function uninstallHealService() {
|
|
11126
12476
|
const log2 = [];
|
|
11127
12477
|
sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
|
|
11128
|
-
if (
|
|
12478
|
+
if (existsSync12(SERVICE_PATH)) {
|
|
11129
12479
|
sh2(`rm -f ${SERVICE_PATH}`);
|
|
11130
12480
|
sh2("systemctl daemon-reload");
|
|
11131
12481
|
log2.push(`removed ${SERVICE_PATH}`);
|
|
@@ -11136,7 +12486,7 @@ function uninstallHealService() {
|
|
|
11136
12486
|
}
|
|
11137
12487
|
function healServiceStatus() {
|
|
11138
12488
|
return {
|
|
11139
|
-
installed:
|
|
12489
|
+
installed: existsSync12(SERVICE_PATH),
|
|
11140
12490
|
active: sh2("systemctl is-active machines-heal.service").out === "active",
|
|
11141
12491
|
enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
|
|
11142
12492
|
};
|
|
@@ -11407,6 +12757,8 @@ program2.name("machines").description("Machine fleet management CLI + MCP for de
|
|
|
11407
12757
|
var manifestCommand = program2.command("manifest").description("Manage the fleet manifest");
|
|
11408
12758
|
var appsCommand = program2.command("apps").description("Manage installed applications per machine");
|
|
11409
12759
|
var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
|
|
12760
|
+
registerEventsCommands(program2, { source: "machines" });
|
|
12761
|
+
var runtimeCommand = program2.command("runtime").description("Watch runtime conditions and emit Hasna events");
|
|
11410
12762
|
var clipboardCommand = program2.command("clipboard").description("Real-time clipboard sync across fleet machines");
|
|
11411
12763
|
var installClaudeCommand = program2.command("install-claude").description("Install or inspect Claude, Codex, and Gemini CLIs");
|
|
11412
12764
|
manifestCommand.command("init").description("Create an empty fleet manifest").action(() => {
|
|
@@ -11645,6 +12997,26 @@ notificationsCommand.command("remove").description("Remove a notification channe
|
|
|
11645
12997
|
const result = removeNotificationChannel(id);
|
|
11646
12998
|
printJsonOrText(result, renderNotificationConfigResult(result), options.json);
|
|
11647
12999
|
});
|
|
13000
|
+
runtimeCommand.command("tmux-watch").description("Watch a tmux pane and emit machines.tmux.pane_died if it disappears").argument("<target>", "tmux pane target, for example %1 or session:window.pane").option("--interval-ms <ms>", "Polling interval in milliseconds", "5000").option("--max-checks <n>", "Stop after N checks instead of watching forever").option("--once", "Probe once and emit machines.tmux.pane_missing when absent", false).option("--no-deliver", "Record the event without webhook delivery").option("-j, --json", "Print JSON output", false).action(async (target, options) => {
|
|
13001
|
+
const maxChecks = options.once ? 1 : options.maxChecks ? parseIntegerOption(options.maxChecks, "max-checks", { min: 1 }) : undefined;
|
|
13002
|
+
const result = await watchTmuxPane({
|
|
13003
|
+
target,
|
|
13004
|
+
intervalMs: parseIntegerOption(options.intervalMs ?? "5000", "interval-ms", { min: 0 }),
|
|
13005
|
+
maxChecks,
|
|
13006
|
+
emitInitialMissing: Boolean(options.once),
|
|
13007
|
+
deliver: options.deliver !== false,
|
|
13008
|
+
onProbe: options.json ? undefined : (probe) => {
|
|
13009
|
+
const status = probe.exists ? source_default.green("present") : source_default.yellow("missing");
|
|
13010
|
+
console.error(`tmux ${probe.target}: ${status}${probe.paneId ? ` ${probe.paneId}` : ""}`);
|
|
13011
|
+
}
|
|
13012
|
+
});
|
|
13013
|
+
printJsonOrText(result, renderKeyValueTable([
|
|
13014
|
+
["target", result.target],
|
|
13015
|
+
["status", result.status],
|
|
13016
|
+
["checks", String(result.checks)],
|
|
13017
|
+
["event", result.emitted?.event.type ?? "none"]
|
|
13018
|
+
]), options.json);
|
|
13019
|
+
});
|
|
11648
13020
|
clipboardCommand.command("init").description("Initialize clipboard sync (generate shared secret)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
11649
13021
|
const key = getOrCreateClipboardKey();
|
|
11650
13022
|
const config = getDefaultClipboardConfig();
|