@hasna/machines 0.0.9 → 0.0.11
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/LICENSE +2 -1
- package/README.md +15 -0
- package/dist/agent/index.js +6 -20
- package/dist/cli/index.js +958 -31
- package/dist/commands/clipboard-daemon.d.ts +6 -0
- package/dist/commands/clipboard-daemon.d.ts.map +1 -0
- package/dist/commands/clipboard-server.d.ts +2 -0
- package/dist/commands/clipboard-server.d.ts.map +1 -1
- package/dist/commands/heal-daemon.d.ts +36 -0
- package/dist/commands/heal-daemon.d.ts.map +1 -0
- package/dist/commands/heal.d.ts +122 -0
- package/dist/commands/heal.d.ts.map +1 -0
- package/dist/index.js +15 -29
- package/dist/mcp/http.d.ts +12 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.js +165 -75
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -5,43 +5,25 @@ var __getProtoOf = Object.getPrototypeOf;
|
|
|
5
5
|
var __defProp = Object.defineProperty;
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
function __accessProp(key) {
|
|
9
|
-
return this[key];
|
|
10
|
-
}
|
|
11
|
-
var __toESMCache_node;
|
|
12
|
-
var __toESMCache_esm;
|
|
13
8
|
var __toESM = (mod, isNodeMode, target) => {
|
|
14
|
-
var canCache = mod != null && typeof mod === "object";
|
|
15
|
-
if (canCache) {
|
|
16
|
-
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
17
|
-
var cached = cache.get(mod);
|
|
18
|
-
if (cached)
|
|
19
|
-
return cached;
|
|
20
|
-
}
|
|
21
9
|
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
22
10
|
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
23
11
|
for (let key of __getOwnPropNames(mod))
|
|
24
12
|
if (!__hasOwnProp.call(to, key))
|
|
25
13
|
__defProp(to, key, {
|
|
26
|
-
get:
|
|
14
|
+
get: () => mod[key],
|
|
27
15
|
enumerable: true
|
|
28
16
|
});
|
|
29
|
-
if (canCache)
|
|
30
|
-
cache.set(mod, to);
|
|
31
17
|
return to;
|
|
32
18
|
};
|
|
33
19
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
34
|
-
var __returnValue = (v) => v;
|
|
35
|
-
function __exportSetter(name, newValue) {
|
|
36
|
-
this[name] = __returnValue.bind(null, newValue);
|
|
37
|
-
}
|
|
38
20
|
var __export = (target, all) => {
|
|
39
21
|
for (var name in all)
|
|
40
22
|
__defProp(target, name, {
|
|
41
23
|
get: all[name],
|
|
42
24
|
enumerable: true,
|
|
43
25
|
configurable: true,
|
|
44
|
-
set:
|
|
26
|
+
set: (newValue) => all[name] = () => newValue
|
|
45
27
|
});
|
|
46
28
|
};
|
|
47
29
|
var __require = import.meta.require;
|
|
@@ -6780,15 +6762,15 @@ var __getProtoOf2 = Object.getPrototypeOf;
|
|
|
6780
6762
|
var __defProp2 = Object.defineProperty;
|
|
6781
6763
|
var __getOwnPropNames2 = Object.getOwnPropertyNames;
|
|
6782
6764
|
var __hasOwnProp2 = Object.prototype.hasOwnProperty;
|
|
6783
|
-
function
|
|
6765
|
+
function __accessProp(key) {
|
|
6784
6766
|
return this[key];
|
|
6785
6767
|
}
|
|
6786
|
-
var
|
|
6787
|
-
var
|
|
6768
|
+
var __toESMCache_node;
|
|
6769
|
+
var __toESMCache_esm;
|
|
6788
6770
|
var __toESM2 = (mod, isNodeMode, target) => {
|
|
6789
6771
|
var canCache = mod != null && typeof mod === "object";
|
|
6790
6772
|
if (canCache) {
|
|
6791
|
-
var cache = isNodeMode ?
|
|
6773
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
6792
6774
|
var cached = cache.get(mod);
|
|
6793
6775
|
if (cached)
|
|
6794
6776
|
return cached;
|
|
@@ -6798,7 +6780,7 @@ var __toESM2 = (mod, isNodeMode, target) => {
|
|
|
6798
6780
|
for (let key of __getOwnPropNames2(mod))
|
|
6799
6781
|
if (!__hasOwnProp2.call(to, key))
|
|
6800
6782
|
__defProp2(to, key, {
|
|
6801
|
-
get:
|
|
6783
|
+
get: __accessProp.bind(mod, key),
|
|
6802
6784
|
enumerable: true
|
|
6803
6785
|
});
|
|
6804
6786
|
if (canCache)
|
|
@@ -6806,9 +6788,9 @@ var __toESM2 = (mod, isNodeMode, target) => {
|
|
|
6806
6788
|
return to;
|
|
6807
6789
|
};
|
|
6808
6790
|
var __commonJS2 = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
6809
|
-
var
|
|
6810
|
-
function
|
|
6811
|
-
this[name] =
|
|
6791
|
+
var __returnValue = (v) => v;
|
|
6792
|
+
function __exportSetter(name, newValue) {
|
|
6793
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6812
6794
|
}
|
|
6813
6795
|
var __export2 = (target, all) => {
|
|
6814
6796
|
for (var name in all)
|
|
@@ -6816,7 +6798,7 @@ var __export2 = (target, all) => {
|
|
|
6816
6798
|
get: all[name],
|
|
6817
6799
|
enumerable: true,
|
|
6818
6800
|
configurable: true,
|
|
6819
|
-
set:
|
|
6801
|
+
set: __exportSetter.bind(all, name)
|
|
6820
6802
|
});
|
|
6821
6803
|
};
|
|
6822
6804
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
@@ -17959,6 +17941,28 @@ function readHistory(historyPath) {
|
|
|
17959
17941
|
return [];
|
|
17960
17942
|
}
|
|
17961
17943
|
}
|
|
17944
|
+
function writeHistory(entries, historyPath) {
|
|
17945
|
+
const path = resolveHistoryPath(historyPath);
|
|
17946
|
+
ensureParentDir(path);
|
|
17947
|
+
writeFileSync5(path, `${JSON.stringify(entries, null, 2)}
|
|
17948
|
+
`, "utf8");
|
|
17949
|
+
}
|
|
17950
|
+
function computeHash(content) {
|
|
17951
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
17952
|
+
}
|
|
17953
|
+
function shouldSkipContent(content, skipPatterns) {
|
|
17954
|
+
const lower = content.toLowerCase();
|
|
17955
|
+
return skipPatterns.some((pattern) => lower.includes(pattern.toLowerCase()));
|
|
17956
|
+
}
|
|
17957
|
+
function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
|
|
17958
|
+
if (Buffer.byteLength(content, "utf8") > maxSizeBytes) {
|
|
17959
|
+
return { ok: false, reason: "content exceeds size limit" };
|
|
17960
|
+
}
|
|
17961
|
+
if (shouldSkipContent(content, skipPatterns)) {
|
|
17962
|
+
return { ok: false, reason: "content matches skip pattern" };
|
|
17963
|
+
}
|
|
17964
|
+
return { ok: true };
|
|
17965
|
+
}
|
|
17962
17966
|
function getOrCreateClipboardKey() {
|
|
17963
17967
|
const keyPath = getClipboardKeyPath();
|
|
17964
17968
|
if (existsSync8(keyPath)) {
|
|
@@ -17985,6 +17989,20 @@ function writeClipboardConfig(config, configPath) {
|
|
|
17985
17989
|
function readClipboardHistory(historyPath) {
|
|
17986
17990
|
return readHistory(historyPath);
|
|
17987
17991
|
}
|
|
17992
|
+
function addClipboardEntry(entry, historyPath) {
|
|
17993
|
+
const entries = readHistory(historyPath);
|
|
17994
|
+
const existing = entries.find((e) => e.hash === entry.hash);
|
|
17995
|
+
if (existing) {
|
|
17996
|
+
existing.timestamp = entry.timestamp;
|
|
17997
|
+
} else {
|
|
17998
|
+
entries.unshift(entry);
|
|
17999
|
+
}
|
|
18000
|
+
const config = readConfig();
|
|
18001
|
+
if (entries.length > config.maxHistory) {
|
|
18002
|
+
entries.length = config.maxHistory;
|
|
18003
|
+
}
|
|
18004
|
+
writeHistory(entries, historyPath);
|
|
18005
|
+
}
|
|
17988
18006
|
function clearClipboardHistory(historyPath) {
|
|
17989
18007
|
const path = resolveHistoryPath(historyPath);
|
|
17990
18008
|
if (existsSync8(path)) {
|
|
@@ -18001,6 +18019,813 @@ function getClipboardStatus(historyPath) {
|
|
|
18001
18019
|
};
|
|
18002
18020
|
}
|
|
18003
18021
|
|
|
18022
|
+
// src/commands/clipboard-daemon.ts
|
|
18023
|
+
import { readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
18024
|
+
import { join as join10 } from "path";
|
|
18025
|
+
import { createHash as createHash3 } from "crypto";
|
|
18026
|
+
|
|
18027
|
+
// src/commands/clipboard-server.ts
|
|
18028
|
+
import { createServer } from "http";
|
|
18029
|
+
import { createHash as createHash2 } from "crypto";
|
|
18030
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
18031
|
+
function readLocalClipboardSync() {
|
|
18032
|
+
const platform4 = process.platform;
|
|
18033
|
+
if (platform4 === "darwin") {
|
|
18034
|
+
const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
|
|
18035
|
+
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
18036
|
+
}
|
|
18037
|
+
if (platform4 === "linux") {
|
|
18038
|
+
if (hasCommand2("wl-paste")) {
|
|
18039
|
+
const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
|
|
18040
|
+
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
18041
|
+
}
|
|
18042
|
+
if (hasCommand2("xclip")) {
|
|
18043
|
+
const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
|
|
18044
|
+
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
18045
|
+
}
|
|
18046
|
+
return "";
|
|
18047
|
+
}
|
|
18048
|
+
return "";
|
|
18049
|
+
}
|
|
18050
|
+
function writeLocalClipboardSync(content) {
|
|
18051
|
+
const platform4 = process.platform;
|
|
18052
|
+
if (platform4 === "darwin") {
|
|
18053
|
+
const result = Bun.spawnSync(["pbcopy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
18054
|
+
return result.exitCode === 0;
|
|
18055
|
+
}
|
|
18056
|
+
if (platform4 === "linux") {
|
|
18057
|
+
if (hasCommand2("wl-copy")) {
|
|
18058
|
+
const result = Bun.spawnSync(["wl-copy"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
18059
|
+
return result.exitCode === 0;
|
|
18060
|
+
}
|
|
18061
|
+
if (hasCommand2("xclip")) {
|
|
18062
|
+
const result = Bun.spawnSync(["xclip", "-selection", "clipboard"], { stdin: new TextEncoder().encode(content), stdout: "ignore", stderr: "ignore" });
|
|
18063
|
+
return result.exitCode === 0;
|
|
18064
|
+
}
|
|
18065
|
+
return false;
|
|
18066
|
+
}
|
|
18067
|
+
return false;
|
|
18068
|
+
}
|
|
18069
|
+
function hasCommand2(binary) {
|
|
18070
|
+
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
|
|
18071
|
+
return result.exitCode === 0;
|
|
18072
|
+
}
|
|
18073
|
+
function loadSharedSecret() {
|
|
18074
|
+
const keyPath = getClipboardKeyPath();
|
|
18075
|
+
try {
|
|
18076
|
+
return readFileSync8(keyPath, "utf8").trim();
|
|
18077
|
+
} catch {
|
|
18078
|
+
return "";
|
|
18079
|
+
}
|
|
18080
|
+
}
|
|
18081
|
+
function authenticate(request) {
|
|
18082
|
+
const authHeader = request.headers["authorization"];
|
|
18083
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
18084
|
+
return false;
|
|
18085
|
+
}
|
|
18086
|
+
const token = authHeader.slice(7);
|
|
18087
|
+
const secret = loadSharedSecret();
|
|
18088
|
+
if (!secret)
|
|
18089
|
+
return false;
|
|
18090
|
+
return createHash2("sha256").update(token).digest("hex") === createHash2("sha256").update(secret).digest("hex");
|
|
18091
|
+
}
|
|
18092
|
+
function jsonResponse(response, status, data) {
|
|
18093
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
18094
|
+
response.end(JSON.stringify(data));
|
|
18095
|
+
}
|
|
18096
|
+
var currentContentHash = null;
|
|
18097
|
+
function getCurrentContentHash() {
|
|
18098
|
+
return currentContentHash;
|
|
18099
|
+
}
|
|
18100
|
+
function setCurrentContentHash(hash) {
|
|
18101
|
+
currentContentHash = hash;
|
|
18102
|
+
}
|
|
18103
|
+
function startClipboardServer(options = {}) {
|
|
18104
|
+
const config = options.config || readClipboardConfig();
|
|
18105
|
+
const port = options.port || config.port;
|
|
18106
|
+
const server = createServer(async (request, response) => {
|
|
18107
|
+
if (!authenticate(request)) {
|
|
18108
|
+
return jsonResponse(response, 401, { error: "unauthorized" });
|
|
18109
|
+
}
|
|
18110
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
|
18111
|
+
if (url.pathname === "/clipboard" && request.method === "POST") {
|
|
18112
|
+
return handleReceiveClipboard(request, response, config);
|
|
18113
|
+
}
|
|
18114
|
+
if (url.pathname === "/clipboard" && request.method === "GET") {
|
|
18115
|
+
return handleGetClipboard(response, config);
|
|
18116
|
+
}
|
|
18117
|
+
if (url.pathname === "/health" && request.method === "GET") {
|
|
18118
|
+
return jsonResponse(response, 200, { ok: true, machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "unknown" });
|
|
18119
|
+
}
|
|
18120
|
+
jsonResponse(response, 404, { error: "not found" });
|
|
18121
|
+
});
|
|
18122
|
+
server.listen(port, "0.0.0.0", () => {});
|
|
18123
|
+
server.on("error", (error) => {
|
|
18124
|
+
console.error(`clipboard server error: ${error.message}`);
|
|
18125
|
+
});
|
|
18126
|
+
return {
|
|
18127
|
+
server,
|
|
18128
|
+
port,
|
|
18129
|
+
close: async () => {
|
|
18130
|
+
await new Promise((resolve2) => server.close(() => resolve2()));
|
|
18131
|
+
}
|
|
18132
|
+
};
|
|
18133
|
+
}
|
|
18134
|
+
function handleReceiveClipboard(request, response, config) {
|
|
18135
|
+
let body = "";
|
|
18136
|
+
request.on("data", (chunk) => {
|
|
18137
|
+
body += chunk;
|
|
18138
|
+
});
|
|
18139
|
+
request.on("end", () => {
|
|
18140
|
+
try {
|
|
18141
|
+
const parsed = JSON.parse(body);
|
|
18142
|
+
const content = parsed.content || "";
|
|
18143
|
+
const contentType = parsed.contentType || "text";
|
|
18144
|
+
const sourceMachine = parsed.sourceMachine || "unknown";
|
|
18145
|
+
if (!content) {
|
|
18146
|
+
return jsonResponse(response, 400, { error: "empty content" });
|
|
18147
|
+
}
|
|
18148
|
+
const hash = computeHash(content);
|
|
18149
|
+
if (hash === currentContentHash) {
|
|
18150
|
+
return jsonResponse(response, 200, { received: false, reason: "loop detected" });
|
|
18151
|
+
}
|
|
18152
|
+
const check2 = sanitizeClipboardForRead(content, config.maxSizeBytes, config.skipPatterns);
|
|
18153
|
+
if (!check2.ok) {
|
|
18154
|
+
return jsonResponse(response, 200, { received: false, reason: check2.reason });
|
|
18155
|
+
}
|
|
18156
|
+
writeLocalClipboardSync(content);
|
|
18157
|
+
currentContentHash = hash;
|
|
18158
|
+
addClipboardEntry({
|
|
18159
|
+
hash,
|
|
18160
|
+
content,
|
|
18161
|
+
contentType,
|
|
18162
|
+
sourceMachine,
|
|
18163
|
+
timestamp: new Date().toISOString()
|
|
18164
|
+
});
|
|
18165
|
+
return jsonResponse(response, 200, { received: true, hash });
|
|
18166
|
+
} catch {
|
|
18167
|
+
return jsonResponse(response, 400, { error: "invalid JSON" });
|
|
18168
|
+
}
|
|
18169
|
+
});
|
|
18170
|
+
}
|
|
18171
|
+
function handleGetClipboard(response, config) {
|
|
18172
|
+
const content = readLocalClipboardSync();
|
|
18173
|
+
if (!content) {
|
|
18174
|
+
return jsonResponse(response, 200, { content: "", hash: null });
|
|
18175
|
+
}
|
|
18176
|
+
const hash = computeHash(content);
|
|
18177
|
+
return jsonResponse(response, 200, { content, hash, contentType: "text" });
|
|
18178
|
+
}
|
|
18179
|
+
|
|
18180
|
+
// src/commands/clipboard-daemon.ts
|
|
18181
|
+
var DAEMON_PID_PATH = join10(getDataDir(), "clipboard-daemon.pid");
|
|
18182
|
+
function readLocalClipboardSync2() {
|
|
18183
|
+
const platform4 = process.platform;
|
|
18184
|
+
if (platform4 === "darwin") {
|
|
18185
|
+
const result = Bun.spawnSync(["pbpaste"], { stdout: "pipe", stderr: "pipe" });
|
|
18186
|
+
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
18187
|
+
}
|
|
18188
|
+
if (platform4 === "linux") {
|
|
18189
|
+
if (hasCommand3("wl-paste")) {
|
|
18190
|
+
const result = Bun.spawnSync(["wl-paste"], { stdout: "pipe", stderr: "pipe" });
|
|
18191
|
+
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
18192
|
+
}
|
|
18193
|
+
if (hasCommand3("xclip")) {
|
|
18194
|
+
const result = Bun.spawnSync(["xclip", "-selection", "clipboard", "-o"], { stdout: "pipe", stderr: "pipe" });
|
|
18195
|
+
return result.exitCode === 0 ? result.stdout.toString("utf8").trim() : "";
|
|
18196
|
+
}
|
|
18197
|
+
return "";
|
|
18198
|
+
}
|
|
18199
|
+
return "";
|
|
18200
|
+
}
|
|
18201
|
+
function hasCommand3(binary) {
|
|
18202
|
+
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], { stdout: "ignore", stderr: "ignore", env: process.env });
|
|
18203
|
+
return result.exitCode === 0;
|
|
18204
|
+
}
|
|
18205
|
+
function hasDisplayServer() {
|
|
18206
|
+
const display = process.env["DISPLAY"] || "";
|
|
18207
|
+
const wayland = process.env["WAYLAND_DISPLAY"] || "";
|
|
18208
|
+
if (display || wayland)
|
|
18209
|
+
return true;
|
|
18210
|
+
if (process.platform === "darwin")
|
|
18211
|
+
return true;
|
|
18212
|
+
if (process.platform === "linux") {
|
|
18213
|
+
try {
|
|
18214
|
+
const result = Bun.spawnSync(["loginctl", "list-sessions", "--no-legend"], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: 2000 });
|
|
18215
|
+
if (result.exitCode === 0) {
|
|
18216
|
+
const sessions = result.stdout.toString("utf8").trim();
|
|
18217
|
+
if (sessions) {
|
|
18218
|
+
for (const line of sessions.split(`
|
|
18219
|
+
`)) {
|
|
18220
|
+
const sessionId = line.trim().split(/\s+/)[0];
|
|
18221
|
+
if (sessionId) {
|
|
18222
|
+
const typeResult = Bun.spawnSync(["loginctl", "show-session", sessionId, "-p", "Type", "--value"], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: 2000 });
|
|
18223
|
+
if (typeResult.exitCode === 0) {
|
|
18224
|
+
const sessionType = typeResult.stdout.toString("utf8").trim();
|
|
18225
|
+
if (sessionType === "wayland" || sessionType === "x11") {
|
|
18226
|
+
return true;
|
|
18227
|
+
}
|
|
18228
|
+
}
|
|
18229
|
+
}
|
|
18230
|
+
}
|
|
18231
|
+
}
|
|
18232
|
+
}
|
|
18233
|
+
} catch {}
|
|
18234
|
+
return false;
|
|
18235
|
+
}
|
|
18236
|
+
return false;
|
|
18237
|
+
}
|
|
18238
|
+
function computeHash2(content) {
|
|
18239
|
+
return createHash3("sha256").update(content).digest("hex").slice(0, 16);
|
|
18240
|
+
}
|
|
18241
|
+
function loadSharedSecret2() {
|
|
18242
|
+
try {
|
|
18243
|
+
return readFileSync9(getClipboardKeyPath(), "utf8").trim();
|
|
18244
|
+
} catch {
|
|
18245
|
+
return "";
|
|
18246
|
+
}
|
|
18247
|
+
}
|
|
18248
|
+
function writePid(pid) {
|
|
18249
|
+
writeFileSync6(DAEMON_PID_PATH, `${pid}
|
|
18250
|
+
`);
|
|
18251
|
+
}
|
|
18252
|
+
function readPid() {
|
|
18253
|
+
try {
|
|
18254
|
+
const pid = Number.parseInt(readFileSync9(DAEMON_PID_PATH, "utf8").trim());
|
|
18255
|
+
return Number.isFinite(pid) ? pid : null;
|
|
18256
|
+
} catch {
|
|
18257
|
+
return null;
|
|
18258
|
+
}
|
|
18259
|
+
}
|
|
18260
|
+
function isProcessRunning(pid) {
|
|
18261
|
+
try {
|
|
18262
|
+
process.kill(pid, 0);
|
|
18263
|
+
return true;
|
|
18264
|
+
} catch {
|
|
18265
|
+
return false;
|
|
18266
|
+
}
|
|
18267
|
+
}
|
|
18268
|
+
function stopClipboardDaemon() {
|
|
18269
|
+
const pid = readPid();
|
|
18270
|
+
if (pid && isProcessRunning(pid)) {
|
|
18271
|
+
process.kill(pid, "SIGTERM");
|
|
18272
|
+
return { stopped: true, pid };
|
|
18273
|
+
}
|
|
18274
|
+
return { stopped: false, pid };
|
|
18275
|
+
}
|
|
18276
|
+
function startClipboardDaemon(port) {
|
|
18277
|
+
const config = readClipboardConfig();
|
|
18278
|
+
const daemonPort = port || config.port;
|
|
18279
|
+
const { server, close } = startClipboardServer({ port: daemonPort });
|
|
18280
|
+
server.on("listening", () => {
|
|
18281
|
+
console.log(`clipboard daemon started on port ${daemonPort} (pid ${process.pid})`);
|
|
18282
|
+
writePid(process.pid);
|
|
18283
|
+
});
|
|
18284
|
+
const secret = loadSharedSecret2();
|
|
18285
|
+
const machineId = process.env["HASNA_MACHINES_MACHINE_ID"] || "unknown";
|
|
18286
|
+
const hasDisplay = hasDisplayServer();
|
|
18287
|
+
if (!hasDisplay) {
|
|
18288
|
+
console.log("clipboard daemon running in receive-only mode (no display server)");
|
|
18289
|
+
}
|
|
18290
|
+
setInterval(async () => {
|
|
18291
|
+
if (!hasDisplay)
|
|
18292
|
+
return;
|
|
18293
|
+
const content = readLocalClipboardSync2();
|
|
18294
|
+
if (!content)
|
|
18295
|
+
return;
|
|
18296
|
+
const hash = computeHash2(content);
|
|
18297
|
+
const currentContentHash2 = getCurrentContentHash();
|
|
18298
|
+
if (hash === currentContentHash2)
|
|
18299
|
+
return;
|
|
18300
|
+
setCurrentContentHash(hash);
|
|
18301
|
+
const peers = await discoverPeers();
|
|
18302
|
+
for (const peer of peers) {
|
|
18303
|
+
try {
|
|
18304
|
+
const res = await fetch(`http://${peer.host}:${peer.port}/clipboard`, {
|
|
18305
|
+
method: "POST",
|
|
18306
|
+
headers: {
|
|
18307
|
+
"content-type": "application/json",
|
|
18308
|
+
authorization: `Bearer ${secret}`
|
|
18309
|
+
},
|
|
18310
|
+
body: JSON.stringify({
|
|
18311
|
+
content,
|
|
18312
|
+
contentType: "text",
|
|
18313
|
+
sourceMachine: machineId
|
|
18314
|
+
}),
|
|
18315
|
+
signal: AbortSignal.timeout(2000)
|
|
18316
|
+
});
|
|
18317
|
+
if (res.ok) {
|
|
18318
|
+
const data = await res.json();
|
|
18319
|
+
if (data["received"] === true) {
|
|
18320
|
+
console.log(`clipboard sent to ${peer.host}`);
|
|
18321
|
+
}
|
|
18322
|
+
}
|
|
18323
|
+
} catch {}
|
|
18324
|
+
}
|
|
18325
|
+
}, 500);
|
|
18326
|
+
}
|
|
18327
|
+
async function discoverPeers() {
|
|
18328
|
+
const config = readClipboardConfig();
|
|
18329
|
+
const peers = [];
|
|
18330
|
+
try {
|
|
18331
|
+
const result = Bun.spawnSync(["tailscale", "status", "--json"], { stdout: "pipe", stderr: "pipe", env: process.env });
|
|
18332
|
+
if (result.exitCode === 0) {
|
|
18333
|
+
const status = JSON.parse(result.stdout.toString("utf8"));
|
|
18334
|
+
const peers_map = status["Peer"] || {};
|
|
18335
|
+
for (const [, peerInfo] of Object.entries(peers_map)) {
|
|
18336
|
+
for (const ip of peerInfo.TailscaleIPs) {
|
|
18337
|
+
if (ip.includes(".") && !ip.endsWith(".1")) {
|
|
18338
|
+
peers.push({ host: ip, port: config.port });
|
|
18339
|
+
}
|
|
18340
|
+
}
|
|
18341
|
+
}
|
|
18342
|
+
}
|
|
18343
|
+
} catch {}
|
|
18344
|
+
const knownPeers = ["100.82.44.120", "100.100.226.69", "100.71.123.34", "100.85.234.92"];
|
|
18345
|
+
for (const ip of knownPeers) {
|
|
18346
|
+
if (!peers.some((p) => p.host === ip)) {
|
|
18347
|
+
peers.push({ host: ip, port: config.port });
|
|
18348
|
+
}
|
|
18349
|
+
}
|
|
18350
|
+
return peers;
|
|
18351
|
+
}
|
|
18352
|
+
|
|
18353
|
+
// src/commands/heal.ts
|
|
18354
|
+
import { existsSync as existsSync9, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
|
|
18355
|
+
import { join as join11 } from "path";
|
|
18356
|
+
var DEFAULT_THRESHOLDS = {
|
|
18357
|
+
reconnect: 3,
|
|
18358
|
+
nmRestart: 7,
|
|
18359
|
+
fallback: 12,
|
|
18360
|
+
reboot: 15
|
|
18361
|
+
};
|
|
18362
|
+
var DEFAULT_HEAL_CONFIG = {
|
|
18363
|
+
version: 1,
|
|
18364
|
+
enabled: true,
|
|
18365
|
+
wifiInterface: "",
|
|
18366
|
+
preferredSsid: "",
|
|
18367
|
+
fallbackSsid: "",
|
|
18368
|
+
internetUrl: "https://1.1.1.1",
|
|
18369
|
+
tailscaleAnchors: [],
|
|
18370
|
+
quorumRequired: 2,
|
|
18371
|
+
intervalSec: 60,
|
|
18372
|
+
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
18373
|
+
rebootMinIntervalSec: 1800,
|
|
18374
|
+
nmRestartMinIntervalSec: 1800,
|
|
18375
|
+
reconnectMinIntervalSec: 120,
|
|
18376
|
+
healthyWindowSec: 300,
|
|
18377
|
+
maxFailedBootRecoveries: 2,
|
|
18378
|
+
bootBackoffSec: 21600,
|
|
18379
|
+
fallbackWindowSec: 600,
|
|
18380
|
+
gpuJobGuard: true,
|
|
18381
|
+
allowReboot: true
|
|
18382
|
+
};
|
|
18383
|
+
function defaultHealState() {
|
|
18384
|
+
return {
|
|
18385
|
+
failCount: 0,
|
|
18386
|
+
bootId: "",
|
|
18387
|
+
bootHealthySince: null,
|
|
18388
|
+
lastRebootAttempt: 0,
|
|
18389
|
+
lastNmRestart: 0,
|
|
18390
|
+
lastReconnect: 0,
|
|
18391
|
+
lastFallback: 0,
|
|
18392
|
+
degradedUntil: 0,
|
|
18393
|
+
pendingRebootRecovery: false,
|
|
18394
|
+
failedBootRecoveries: 0,
|
|
18395
|
+
rebootSuppressUntil: 0
|
|
18396
|
+
};
|
|
18397
|
+
}
|
|
18398
|
+
function getHealConfigPath() {
|
|
18399
|
+
return process.env["HASNA_MACHINES_HEAL_CONFIG_PATH"] || join11(getDataDir(), "heal-config.json");
|
|
18400
|
+
}
|
|
18401
|
+
function getHealStatePath() {
|
|
18402
|
+
return process.env["HASNA_MACHINES_HEAL_STATE_PATH"] || join11(getDataDir(), "heal-state.json");
|
|
18403
|
+
}
|
|
18404
|
+
function readHealConfig(path) {
|
|
18405
|
+
const p = path || getHealConfigPath();
|
|
18406
|
+
if (!existsSync9(p))
|
|
18407
|
+
return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
|
|
18408
|
+
const parsed = JSON.parse(readFileSync10(p, "utf8"));
|
|
18409
|
+
return {
|
|
18410
|
+
...DEFAULT_HEAL_CONFIG,
|
|
18411
|
+
...parsed,
|
|
18412
|
+
thresholds: { ...DEFAULT_THRESHOLDS, ...parsed.thresholds || {} },
|
|
18413
|
+
tailscaleAnchors: parsed.tailscaleAnchors ?? []
|
|
18414
|
+
};
|
|
18415
|
+
}
|
|
18416
|
+
function writeHealConfig(config, path) {
|
|
18417
|
+
const p = path || getHealConfigPath();
|
|
18418
|
+
ensureParentDir(p);
|
|
18419
|
+
writeFileSync7(p, `${JSON.stringify(config, null, 2)}
|
|
18420
|
+
`, "utf8");
|
|
18421
|
+
}
|
|
18422
|
+
function readHealState(path) {
|
|
18423
|
+
const p = path || getHealStatePath();
|
|
18424
|
+
if (!existsSync9(p))
|
|
18425
|
+
return defaultHealState();
|
|
18426
|
+
try {
|
|
18427
|
+
return { ...defaultHealState(), ...JSON.parse(readFileSync10(p, "utf8")) };
|
|
18428
|
+
} catch {
|
|
18429
|
+
return defaultHealState();
|
|
18430
|
+
}
|
|
18431
|
+
}
|
|
18432
|
+
function writeHealState(state, path) {
|
|
18433
|
+
const p = path || getHealStatePath();
|
|
18434
|
+
ensureParentDir(p);
|
|
18435
|
+
writeFileSync7(p, `${JSON.stringify(state, null, 2)}
|
|
18436
|
+
`, "utf8");
|
|
18437
|
+
}
|
|
18438
|
+
function evaluateHealth(probe, config, state) {
|
|
18439
|
+
const reasons = [];
|
|
18440
|
+
const inDegraded = state.degradedUntil > 0;
|
|
18441
|
+
const acceptableSsid = probe.associatedSsid === config.preferredSsid || config.fallbackSsid !== "" && inDegraded && probe.associatedSsid === config.fallbackSsid;
|
|
18442
|
+
if (!acceptableSsid)
|
|
18443
|
+
reasons.push(`wrong-ssid:${probe.associatedSsid ?? "none"}`);
|
|
18444
|
+
if (!probe.gatewayReachable)
|
|
18445
|
+
reasons.push("gateway-unreachable");
|
|
18446
|
+
let remoteScore = 0;
|
|
18447
|
+
for (const [anchor, ok] of Object.entries(probe.anchorsReachable)) {
|
|
18448
|
+
if (ok)
|
|
18449
|
+
remoteScore += 1;
|
|
18450
|
+
else
|
|
18451
|
+
reasons.push(`anchor-down:${anchor}`);
|
|
18452
|
+
}
|
|
18453
|
+
if (probe.internetReachable)
|
|
18454
|
+
remoteScore += 1;
|
|
18455
|
+
else
|
|
18456
|
+
reasons.push("internet-down");
|
|
18457
|
+
const localOk = acceptableSsid && probe.gatewayReachable;
|
|
18458
|
+
const quorumOk = remoteScore >= config.quorumRequired;
|
|
18459
|
+
if (!quorumOk)
|
|
18460
|
+
reasons.push(`quorum:${remoteScore}/${config.quorumRequired}`);
|
|
18461
|
+
return { healthy: localOk && quorumOk, remoteScore, reasons };
|
|
18462
|
+
}
|
|
18463
|
+
function decideAction(input) {
|
|
18464
|
+
const { healthy, now, gpuBusy, config, currentBootId } = input;
|
|
18465
|
+
const s = { ...input.state };
|
|
18466
|
+
const t = config.thresholds;
|
|
18467
|
+
if (s.bootId !== currentBootId) {
|
|
18468
|
+
s.bootId = currentBootId;
|
|
18469
|
+
s.bootHealthySince = null;
|
|
18470
|
+
s.failCount = 0;
|
|
18471
|
+
}
|
|
18472
|
+
if (healthy) {
|
|
18473
|
+
s.failCount = 0;
|
|
18474
|
+
if (s.bootHealthySince === null)
|
|
18475
|
+
s.bootHealthySince = now;
|
|
18476
|
+
if (now - s.bootHealthySince >= config.healthyWindowSec) {
|
|
18477
|
+
s.failedBootRecoveries = 0;
|
|
18478
|
+
s.rebootSuppressUntil = 0;
|
|
18479
|
+
s.pendingRebootRecovery = false;
|
|
18480
|
+
}
|
|
18481
|
+
if (s.degradedUntil > 0 && now >= s.degradedUntil) {
|
|
18482
|
+
s.degradedUntil = 0;
|
|
18483
|
+
return { action: "restore_preferred", state: s };
|
|
18484
|
+
}
|
|
18485
|
+
return { action: "none", state: s };
|
|
18486
|
+
}
|
|
18487
|
+
s.failCount += 1;
|
|
18488
|
+
s.bootHealthySince = null;
|
|
18489
|
+
let tier = "none";
|
|
18490
|
+
if (s.failCount >= t.reboot)
|
|
18491
|
+
tier = "reboot";
|
|
18492
|
+
else if (s.failCount >= t.fallback && config.fallbackSsid !== "")
|
|
18493
|
+
tier = "fallback";
|
|
18494
|
+
else if (s.failCount >= t.nmRestart)
|
|
18495
|
+
tier = "nmRestart";
|
|
18496
|
+
else if (s.failCount >= t.reconnect)
|
|
18497
|
+
tier = "reconnect";
|
|
18498
|
+
const tryReconnect = (reason) => {
|
|
18499
|
+
if (now - s.lastReconnect >= config.reconnectMinIntervalSec) {
|
|
18500
|
+
s.lastReconnect = now;
|
|
18501
|
+
return { action: "reconnect_wifi", suppressedReason: reason, state: s };
|
|
18502
|
+
}
|
|
18503
|
+
return { action: "none", suppressedReason: reason, state: s };
|
|
18504
|
+
};
|
|
18505
|
+
switch (tier) {
|
|
18506
|
+
case "reconnect":
|
|
18507
|
+
return tryReconnect();
|
|
18508
|
+
case "nmRestart":
|
|
18509
|
+
if (now - s.lastNmRestart >= config.nmRestartMinIntervalSec) {
|
|
18510
|
+
s.lastNmRestart = now;
|
|
18511
|
+
return { action: "restart_nm", state: s };
|
|
18512
|
+
}
|
|
18513
|
+
return tryReconnect();
|
|
18514
|
+
case "fallback":
|
|
18515
|
+
if (now - s.lastFallback >= config.fallbackWindowSec) {
|
|
18516
|
+
s.lastFallback = now;
|
|
18517
|
+
s.degradedUntil = now + config.fallbackWindowSec;
|
|
18518
|
+
return { action: "fallback_ssid", state: s };
|
|
18519
|
+
}
|
|
18520
|
+
return tryReconnect();
|
|
18521
|
+
case "reboot": {
|
|
18522
|
+
let reason = null;
|
|
18523
|
+
if (!config.allowReboot)
|
|
18524
|
+
reason = "disabled";
|
|
18525
|
+
else if (now < s.rebootSuppressUntil)
|
|
18526
|
+
reason = "loop";
|
|
18527
|
+
else if (config.gpuJobGuard && gpuBusy)
|
|
18528
|
+
reason = "gpu";
|
|
18529
|
+
else if (now - s.lastRebootAttempt < config.rebootMinIntervalSec)
|
|
18530
|
+
reason = "rate";
|
|
18531
|
+
if (reason)
|
|
18532
|
+
return tryReconnect(reason);
|
|
18533
|
+
if (s.pendingRebootRecovery) {
|
|
18534
|
+
s.failedBootRecoveries += 1;
|
|
18535
|
+
if (s.failedBootRecoveries >= config.maxFailedBootRecoveries) {
|
|
18536
|
+
s.rebootSuppressUntil = now + config.bootBackoffSec;
|
|
18537
|
+
return tryReconnect("loop");
|
|
18538
|
+
}
|
|
18539
|
+
}
|
|
18540
|
+
s.lastRebootAttempt = now;
|
|
18541
|
+
s.pendingRebootRecovery = true;
|
|
18542
|
+
return { action: "reboot", state: s };
|
|
18543
|
+
}
|
|
18544
|
+
default:
|
|
18545
|
+
return { action: "none", state: s };
|
|
18546
|
+
}
|
|
18547
|
+
}
|
|
18548
|
+
function sh(cmd, timeoutMs = 8000) {
|
|
18549
|
+
const r = Bun.spawnSync(["bash", "-lc", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
|
|
18550
|
+
return { ok: r.exitCode === 0, out: r.stdout.toString("utf8").trim() };
|
|
18551
|
+
}
|
|
18552
|
+
function getCurrentBootId() {
|
|
18553
|
+
try {
|
|
18554
|
+
return readFileSync10("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
18555
|
+
} catch {
|
|
18556
|
+
return "";
|
|
18557
|
+
}
|
|
18558
|
+
}
|
|
18559
|
+
function detectWifiInterface() {
|
|
18560
|
+
const r = sh(`nmcli -t -f DEVICE,TYPE device status 2>/dev/null | awk -F: '$2=="wifi"{print $1; exit}'`);
|
|
18561
|
+
return r.ok ? r.out : "";
|
|
18562
|
+
}
|
|
18563
|
+
function detectGateway() {
|
|
18564
|
+
const r = sh(`ip route 2>/dev/null | awk '/^default/{print $3; exit}'`);
|
|
18565
|
+
return r.ok ? r.out : "";
|
|
18566
|
+
}
|
|
18567
|
+
function getAssociatedSsid() {
|
|
18568
|
+
const r = sh(`iwgetid -r 2>/dev/null || nmcli -t -f active,ssid dev wifi 2>/dev/null | awk -F: '/^yes/{print $2; exit}'`);
|
|
18569
|
+
return r.ok && r.out ? r.out : null;
|
|
18570
|
+
}
|
|
18571
|
+
function pingHost(host) {
|
|
18572
|
+
if (!host)
|
|
18573
|
+
return false;
|
|
18574
|
+
return sh(`ping -c1 -W2 ${host} >/dev/null 2>&1 && echo ok`, 5000).out === "ok";
|
|
18575
|
+
}
|
|
18576
|
+
function internetReachable(url) {
|
|
18577
|
+
return sh(`curl -sf -m5 -o /dev/null ${url} && echo ok`, 8000).out === "ok";
|
|
18578
|
+
}
|
|
18579
|
+
function tailscalePing(host) {
|
|
18580
|
+
return sh(`timeout 8 tailscale ping --until-direct=false ${host} 2>/dev/null | grep -q pong && echo ok`, 1e4).out === "ok";
|
|
18581
|
+
}
|
|
18582
|
+
function gpuBusy() {
|
|
18583
|
+
const r = sh(`command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-compute-apps=pid --format=csv,noheader 2>/dev/null | grep -q . && echo busy`, 6000);
|
|
18584
|
+
return r.out === "busy";
|
|
18585
|
+
}
|
|
18586
|
+
function discoverAnchors() {
|
|
18587
|
+
const r = sh(`tailscale status --json 2>/dev/null`);
|
|
18588
|
+
if (!r.ok)
|
|
18589
|
+
return [];
|
|
18590
|
+
try {
|
|
18591
|
+
const status = JSON.parse(r.out);
|
|
18592
|
+
const anchors = [];
|
|
18593
|
+
for (const peer of Object.values(status.Peer || {})) {
|
|
18594
|
+
const name = peer.HostName || (peer.DNSName || "").split(".")[0];
|
|
18595
|
+
if (name)
|
|
18596
|
+
anchors.push(name);
|
|
18597
|
+
}
|
|
18598
|
+
return anchors;
|
|
18599
|
+
} catch {
|
|
18600
|
+
return [];
|
|
18601
|
+
}
|
|
18602
|
+
}
|
|
18603
|
+
function probeHealth(config) {
|
|
18604
|
+
const gw = config.wifiInterface ? detectGateway() : detectGateway();
|
|
18605
|
+
const anchors = config.tailscaleAnchors.length > 0 ? config.tailscaleAnchors : discoverAnchors().slice(0, 3);
|
|
18606
|
+
const anchorsReachable = {};
|
|
18607
|
+
for (const a of anchors)
|
|
18608
|
+
anchorsReachable[a] = tailscalePing(a);
|
|
18609
|
+
return {
|
|
18610
|
+
associatedSsid: getAssociatedSsid(),
|
|
18611
|
+
gatewayReachable: pingHost(gw),
|
|
18612
|
+
anchorsReachable,
|
|
18613
|
+
internetReachable: internetReachable(config.internetUrl)
|
|
18614
|
+
};
|
|
18615
|
+
}
|
|
18616
|
+
function executeAction(action, config) {
|
|
18617
|
+
const iface = config.wifiInterface || detectWifiInterface();
|
|
18618
|
+
switch (action) {
|
|
18619
|
+
case "reconnect_wifi":
|
|
18620
|
+
sh(`nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
18621
|
+
return `reconnected wifi to ${config.preferredSsid}`;
|
|
18622
|
+
case "restart_nm":
|
|
18623
|
+
sh(`systemctl restart NetworkManager 2>&1; sleep 5; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 40000);
|
|
18624
|
+
return "restarted NetworkManager";
|
|
18625
|
+
case "fallback_ssid":
|
|
18626
|
+
sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect yes 2>&1; nmcli connection up "${config.fallbackSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
18627
|
+
return `switched to degraded fallback ${config.fallbackSsid}`;
|
|
18628
|
+
case "restore_preferred":
|
|
18629
|
+
sh(`nmcli connection modify "${config.fallbackSsid}" connection.autoconnect no 2>&1; nmcli connection up "${config.preferredSsid}" 2>&1; tailscale up 2>&1 || true`, 30000);
|
|
18630
|
+
return `restored preferred ${config.preferredSsid}`;
|
|
18631
|
+
case "reboot":
|
|
18632
|
+
sh(`systemctl reboot 2>&1 || reboot 2>&1`, 1e4);
|
|
18633
|
+
return "reboot issued";
|
|
18634
|
+
default:
|
|
18635
|
+
return "no action";
|
|
18636
|
+
}
|
|
18637
|
+
}
|
|
18638
|
+
|
|
18639
|
+
// src/commands/heal-daemon.ts
|
|
18640
|
+
import { existsSync as existsSync10, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
|
|
18641
|
+
import { join as join12 } from "path";
|
|
18642
|
+
var DAEMON_PID_PATH2 = join12(getDataDir(), "heal-daemon.pid");
|
|
18643
|
+
var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
|
|
18644
|
+
var SYSTEM_CONF = "/etc/systemd/system.conf";
|
|
18645
|
+
function log(msg) {
|
|
18646
|
+
console.log(`${new Date().toISOString()} [machines-heal] ${msg}`);
|
|
18647
|
+
}
|
|
18648
|
+
function runHealOnce(config, opts = {}) {
|
|
18649
|
+
const state = readHealState();
|
|
18650
|
+
const probe = probeHealth(config);
|
|
18651
|
+
const health = evaluateHealth(probe, config, state);
|
|
18652
|
+
const busy = config.gpuJobGuard ? gpuBusy() : false;
|
|
18653
|
+
const decision = decideAction({
|
|
18654
|
+
state,
|
|
18655
|
+
healthy: health.healthy,
|
|
18656
|
+
now: Math.floor(Date.now() / 1000),
|
|
18657
|
+
gpuBusy: busy,
|
|
18658
|
+
config,
|
|
18659
|
+
currentBootId: getCurrentBootId()
|
|
18660
|
+
});
|
|
18661
|
+
let executed = "skipped (dry-run)";
|
|
18662
|
+
if (!opts.dryRun) {
|
|
18663
|
+
writeHealState(decision.state);
|
|
18664
|
+
if (decision.action !== "none")
|
|
18665
|
+
executed = executeAction(decision.action, config);
|
|
18666
|
+
else
|
|
18667
|
+
executed = "no action";
|
|
18668
|
+
}
|
|
18669
|
+
const result = {
|
|
18670
|
+
healthy: health.healthy,
|
|
18671
|
+
action: decision.action,
|
|
18672
|
+
suppressedReason: decision.suppressedReason,
|
|
18673
|
+
reasons: health.reasons,
|
|
18674
|
+
remoteScore: health.remoteScore,
|
|
18675
|
+
failCount: decision.state.failCount,
|
|
18676
|
+
executed
|
|
18677
|
+
};
|
|
18678
|
+
const sup = decision.suppressedReason ? ` suppressed=${decision.suppressedReason}` : "";
|
|
18679
|
+
log(health.healthy ? `healthy (quorum ${health.remoteScore}) action=${decision.action} ${executed}` : `UNHEALTHY [${health.reasons.join(",")}] fails=${decision.state.failCount} action=${decision.action}${sup} -> ${executed}`);
|
|
18680
|
+
return result;
|
|
18681
|
+
}
|
|
18682
|
+
function writePid2(pid) {
|
|
18683
|
+
writeFileSync8(DAEMON_PID_PATH2, `${pid}
|
|
18684
|
+
`);
|
|
18685
|
+
}
|
|
18686
|
+
function readPid2() {
|
|
18687
|
+
try {
|
|
18688
|
+
const pid = Number.parseInt(readFileSync11(DAEMON_PID_PATH2, "utf8").trim());
|
|
18689
|
+
return Number.isFinite(pid) ? pid : null;
|
|
18690
|
+
} catch {
|
|
18691
|
+
return null;
|
|
18692
|
+
}
|
|
18693
|
+
}
|
|
18694
|
+
function isProcessRunning2(pid) {
|
|
18695
|
+
try {
|
|
18696
|
+
process.kill(pid, 0);
|
|
18697
|
+
return true;
|
|
18698
|
+
} catch {
|
|
18699
|
+
return false;
|
|
18700
|
+
}
|
|
18701
|
+
}
|
|
18702
|
+
function stopHealDaemon() {
|
|
18703
|
+
const pid = readPid2();
|
|
18704
|
+
if (pid && isProcessRunning2(pid)) {
|
|
18705
|
+
process.kill(pid, "SIGTERM");
|
|
18706
|
+
return { stopped: true, pid };
|
|
18707
|
+
}
|
|
18708
|
+
return { stopped: false, pid };
|
|
18709
|
+
}
|
|
18710
|
+
function startHealDaemon() {
|
|
18711
|
+
const config = readHealConfig();
|
|
18712
|
+
if (!config.preferredSsid) {
|
|
18713
|
+
log("refusing to start: preferredSsid is not configured (run `machines heal config --set ...`)");
|
|
18714
|
+
process.exit(1);
|
|
18715
|
+
}
|
|
18716
|
+
writePid2(process.pid);
|
|
18717
|
+
log(`daemon started (pid ${process.pid}) interval=${config.intervalSec}s preferred=${config.preferredSsid}`);
|
|
18718
|
+
const tick = () => {
|
|
18719
|
+
try {
|
|
18720
|
+
runHealOnce(config);
|
|
18721
|
+
} catch (err) {
|
|
18722
|
+
log(`tick error: ${err.message}`);
|
|
18723
|
+
}
|
|
18724
|
+
};
|
|
18725
|
+
tick();
|
|
18726
|
+
setInterval(tick, Math.max(10, config.intervalSec) * 1000);
|
|
18727
|
+
}
|
|
18728
|
+
function sh2(cmd, timeoutMs = 15000) {
|
|
18729
|
+
const r = Bun.spawnSync(["bash", "-lc", cmd], { stdout: "pipe", stderr: "pipe", env: process.env, timeout: timeoutMs });
|
|
18730
|
+
return { ok: r.exitCode === 0, out: `${r.stdout.toString("utf8")}${r.stderr.toString("utf8")}`.trim() };
|
|
18731
|
+
}
|
|
18732
|
+
function applyDeterminism(config) {
|
|
18733
|
+
const iface = config.wifiInterface || detectWifiInterface();
|
|
18734
|
+
const log2 = [];
|
|
18735
|
+
if (!config.preferredSsid)
|
|
18736
|
+
return ["no preferredSsid configured; skipping determinism"];
|
|
18737
|
+
sh2(`nmcli connection modify "${config.preferredSsid}" connection.autoconnect yes connection.autoconnect-priority 10 802-11-wireless.powersave 2`);
|
|
18738
|
+
log2.push(`pinned ${config.preferredSsid} (autoconnect, priority 10, powersave off)`);
|
|
18739
|
+
const profiles = sh2(`nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2 ~ /wireless/{print $1}'`).out.split(`
|
|
18740
|
+
`).filter(Boolean);
|
|
18741
|
+
for (const p of profiles) {
|
|
18742
|
+
if (p === config.preferredSsid)
|
|
18743
|
+
continue;
|
|
18744
|
+
if (p === config.fallbackSsid) {
|
|
18745
|
+
sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
|
|
18746
|
+
log2.push(`disabled autoconnect on fallback ${p}`);
|
|
18747
|
+
continue;
|
|
18748
|
+
}
|
|
18749
|
+
sh2(`nmcli connection modify "${p}" connection.autoconnect no`);
|
|
18750
|
+
log2.push(`disabled autoconnect on ${p}`);
|
|
18751
|
+
}
|
|
18752
|
+
if (iface) {
|
|
18753
|
+
sh2(`iw dev ${iface} set power_save off 2>/dev/null || true`);
|
|
18754
|
+
log2.push(`power_save off on ${iface}`);
|
|
18755
|
+
}
|
|
18756
|
+
return log2;
|
|
18757
|
+
}
|
|
18758
|
+
function enableHardwareWatchdog() {
|
|
18759
|
+
const log2 = [];
|
|
18760
|
+
if (!existsSync10(SYSTEM_CONF))
|
|
18761
|
+
return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
|
|
18762
|
+
let conf = readFileSync11(SYSTEM_CONF, "utf8");
|
|
18763
|
+
const set = (key, value) => {
|
|
18764
|
+
const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
|
|
18765
|
+
if (re.test(conf))
|
|
18766
|
+
conf = conf.replace(re, `${key}=${value}`);
|
|
18767
|
+
else
|
|
18768
|
+
conf += `
|
|
18769
|
+
${key}=${value}
|
|
18770
|
+
`;
|
|
18771
|
+
};
|
|
18772
|
+
set("RuntimeWatchdogSec", "20s");
|
|
18773
|
+
set("RebootWatchdogSec", "2min");
|
|
18774
|
+
writeFileSync8(SYSTEM_CONF, conf);
|
|
18775
|
+
sh2("systemctl daemon-reexec");
|
|
18776
|
+
log2.push("hardware watchdog: RuntimeWatchdogSec=20s RebootWatchdogSec=2min");
|
|
18777
|
+
return log2;
|
|
18778
|
+
}
|
|
18779
|
+
function binPath() {
|
|
18780
|
+
const r = sh2("command -v machines");
|
|
18781
|
+
return r.ok && r.out ? r.out.split(`
|
|
18782
|
+
`)[0].trim() : "machines";
|
|
18783
|
+
}
|
|
18784
|
+
function installHealService() {
|
|
18785
|
+
const log2 = [];
|
|
18786
|
+
const exec = binPath();
|
|
18787
|
+
const unit = `[Unit]
|
|
18788
|
+
Description=Hasna machines self-healing network watchdog
|
|
18789
|
+
After=network.target NetworkManager.service tailscaled.service
|
|
18790
|
+
Wants=network.target
|
|
18791
|
+
|
|
18792
|
+
[Service]
|
|
18793
|
+
Type=simple
|
|
18794
|
+
ExecStart=${exec} heal daemon
|
|
18795
|
+
Restart=always
|
|
18796
|
+
RestartSec=10
|
|
18797
|
+
# Persisted state/config live in root's data dir.
|
|
18798
|
+
Environment=HOME=/root
|
|
18799
|
+
|
|
18800
|
+
[Install]
|
|
18801
|
+
WantedBy=multi-user.target
|
|
18802
|
+
`;
|
|
18803
|
+
writeFileSync8(SERVICE_PATH, unit);
|
|
18804
|
+
sh2("systemctl daemon-reload");
|
|
18805
|
+
sh2("systemctl enable --now machines-heal.service");
|
|
18806
|
+
log2.push(`installed + enabled ${SERVICE_PATH} (ExecStart=${exec} heal daemon)`);
|
|
18807
|
+
return log2;
|
|
18808
|
+
}
|
|
18809
|
+
function uninstallHealService() {
|
|
18810
|
+
const log2 = [];
|
|
18811
|
+
sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
|
|
18812
|
+
if (existsSync10(SERVICE_PATH)) {
|
|
18813
|
+
sh2(`rm -f ${SERVICE_PATH}`);
|
|
18814
|
+
sh2("systemctl daemon-reload");
|
|
18815
|
+
log2.push(`removed ${SERVICE_PATH}`);
|
|
18816
|
+
} else {
|
|
18817
|
+
log2.push("service not installed");
|
|
18818
|
+
}
|
|
18819
|
+
return log2;
|
|
18820
|
+
}
|
|
18821
|
+
function healServiceStatus() {
|
|
18822
|
+
return {
|
|
18823
|
+
installed: existsSync10(SERVICE_PATH),
|
|
18824
|
+
active: sh2("systemctl is-active machines-heal.service").out === "active",
|
|
18825
|
+
enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
|
|
18826
|
+
};
|
|
18827
|
+
}
|
|
18828
|
+
|
|
18004
18829
|
// src/cli-utils.ts
|
|
18005
18830
|
function parseIntegerOption(value, label, constraints = {}) {
|
|
18006
18831
|
const parsed = Number.parseInt(value, 10);
|
|
@@ -18032,7 +18857,7 @@ ${items.map((item) => `- ${item}`).join(`
|
|
|
18032
18857
|
|
|
18033
18858
|
// src/cli/index.ts
|
|
18034
18859
|
import { rmSync as rmSync2 } from "fs";
|
|
18035
|
-
import { readFileSync as
|
|
18860
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
18036
18861
|
var program2 = new Command;
|
|
18037
18862
|
function printJsonOrText(data, text, json = false) {
|
|
18038
18863
|
if (json || program2.opts().quiet) {
|
|
@@ -18179,7 +19004,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
18179
19004
|
console.error("error: --from-stdin requires piped input");
|
|
18180
19005
|
process.exit(1);
|
|
18181
19006
|
}
|
|
18182
|
-
const input =
|
|
19007
|
+
const input = readFileSync12(0, "utf8");
|
|
18183
19008
|
const machine2 = JSON.parse(input);
|
|
18184
19009
|
console.log(JSON.stringify(manifestAdd(machine2), null, 2));
|
|
18185
19010
|
return;
|
|
@@ -18358,6 +19183,14 @@ clipboardCommand.command("key").description("Show or rotate the shared secret ke
|
|
|
18358
19183
|
const key = getOrCreateClipboardKey();
|
|
18359
19184
|
printJsonOrText({ key }, key, options.json);
|
|
18360
19185
|
});
|
|
19186
|
+
clipboardCommand.command("start").description("Start clipboard sync daemon").option("--port <port>", "Port to listen on").action((options) => {
|
|
19187
|
+
const port = options.port ? Number(options.port) : undefined;
|
|
19188
|
+
startClipboardDaemon(port);
|
|
19189
|
+
});
|
|
19190
|
+
clipboardCommand.command("stop").description("Stop clipboard sync daemon").action(() => {
|
|
19191
|
+
const result = stopClipboardDaemon();
|
|
19192
|
+
console.log(result.stopped ? `daemon stopped (pid ${result.pid})` : "daemon not running");
|
|
19193
|
+
});
|
|
18361
19194
|
installClaudeCommand.command("status").description("Check installed state for Claude, Codex, and Gemini CLIs").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to inspect (claude, codex, gemini)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18362
19195
|
const result = getClaudeCliStatus(options.machine, options.tool);
|
|
18363
19196
|
printJsonOrText(result, renderClaudeStatusResult(result), options.json);
|
|
@@ -18410,4 +19243,98 @@ program2.command("serve").description("Serve a local fleet dashboard and JSON AP
|
|
|
18410
19243
|
const server = startDashboardServer({ host: info.host, port: info.port });
|
|
18411
19244
|
console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
|
|
18412
19245
|
});
|
|
19246
|
+
var healCommand = program2.command("heal").description("Self-healing network watchdog: keeps a Wi-Fi node reachable (SSID pinning + peer-reachability + gated reboot)");
|
|
19247
|
+
function requireRoot() {
|
|
19248
|
+
const uid = process.getuid ? process.getuid() : 1;
|
|
19249
|
+
if (uid !== 0) {
|
|
19250
|
+
console.error(source_default.red("error: this command must run as root (try: sudo machines heal install)"));
|
|
19251
|
+
return false;
|
|
19252
|
+
}
|
|
19253
|
+
return true;
|
|
19254
|
+
}
|
|
19255
|
+
healCommand.command("config").description(`View or update self-healing config (e.g. --set '{"preferredSsid":"X81ND","fallbackSsid":"DIGI-s2N5"}')`).option("--set <json>", "Merge a JSON object into the config").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
19256
|
+
if (options.set) {
|
|
19257
|
+
const current = readHealConfig();
|
|
19258
|
+
const partial = JSON.parse(options.set);
|
|
19259
|
+
writeHealConfig({
|
|
19260
|
+
...current,
|
|
19261
|
+
...partial,
|
|
19262
|
+
thresholds: { ...current.thresholds, ...partial.thresholds || {} }
|
|
19263
|
+
});
|
|
19264
|
+
}
|
|
19265
|
+
const config = readHealConfig();
|
|
19266
|
+
printJsonOrText(config, renderKeyValueTable([
|
|
19267
|
+
["enabled", String(config.enabled)],
|
|
19268
|
+
["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
|
|
19269
|
+
["fallbackSsid", config.fallbackSsid || "(none)"],
|
|
19270
|
+
["anchors", config.tailscaleAnchors.length ? config.tailscaleAnchors.join(", ") : "(auto-discover)"],
|
|
19271
|
+
["quorumRequired", String(config.quorumRequired)],
|
|
19272
|
+
["intervalSec", String(config.intervalSec)],
|
|
19273
|
+
["thresholds", `reconnect=${config.thresholds.reconnect} nm=${config.thresholds.nmRestart} fallback=${config.thresholds.fallback} reboot=${config.thresholds.reboot}`],
|
|
19274
|
+
["allowReboot", String(config.allowReboot)],
|
|
19275
|
+
["gpuJobGuard", String(config.gpuJobGuard)]
|
|
19276
|
+
]), options.json);
|
|
19277
|
+
});
|
|
19278
|
+
healCommand.command("check").description("Run one health + decision tick read-only (no side effects)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
19279
|
+
const result = runHealOnce(readHealConfig(), { dryRun: true });
|
|
19280
|
+
printJsonOrText(result, renderList("heal check", [
|
|
19281
|
+
`health: ${result.healthy ? source_default.green("HEALTHY") : source_default.red("UNHEALTHY")} (remote quorum ${result.remoteScore})`,
|
|
19282
|
+
`reasons: ${result.reasons.length ? result.reasons.join(", ") : "none"}`,
|
|
19283
|
+
`would do: ${result.action}${result.suppressedReason ? ` (reboot suppressed: ${result.suppressedReason})` : ""}`,
|
|
19284
|
+
`consecutive fails: ${result.failCount}`
|
|
19285
|
+
]), options.json);
|
|
19286
|
+
});
|
|
19287
|
+
healCommand.command("status").description("Show watchdog service status and last persisted state").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
19288
|
+
const svc = healServiceStatus();
|
|
19289
|
+
const state = readHealState();
|
|
19290
|
+
const config = readHealConfig();
|
|
19291
|
+
printJsonOrText({ service: svc, state, config }, renderKeyValueTable([
|
|
19292
|
+
["service installed", svc.installed ? source_default.green("yes") : "no"],
|
|
19293
|
+
["service active", svc.active ? source_default.green("yes") : source_default.yellow("no")],
|
|
19294
|
+
["service enabled", svc.enabled ? "yes" : "no"],
|
|
19295
|
+
["preferredSsid", config.preferredSsid || source_default.yellow("(unset)")],
|
|
19296
|
+
["consecutive fails", String(state.failCount)],
|
|
19297
|
+
["pending reboot recovery", String(state.pendingRebootRecovery)],
|
|
19298
|
+
["failed boot recoveries", String(state.failedBootRecoveries)]
|
|
19299
|
+
]), options.json);
|
|
19300
|
+
});
|
|
19301
|
+
healCommand.command("daemon").description("Run the watchdog loop in the foreground (used by systemd)").action(() => {
|
|
19302
|
+
startHealDaemon();
|
|
19303
|
+
});
|
|
19304
|
+
healCommand.command("stop").description("Stop a foreground daemon started via `heal daemon`").action(() => {
|
|
19305
|
+
const r = stopHealDaemon();
|
|
19306
|
+
console.log(r.stopped ? `stopped heal daemon (pid ${r.pid})` : "heal daemon not running");
|
|
19307
|
+
});
|
|
19308
|
+
healCommand.command("determinism").description("Pin the preferred SSID, disable other autoconnects, turn off Wi-Fi power save").action(() => {
|
|
19309
|
+
const log2 = applyDeterminism(readHealConfig());
|
|
19310
|
+
console.log(renderList("determinism", log2));
|
|
19311
|
+
});
|
|
19312
|
+
healCommand.command("install").description("Install the watchdog: determinism + hardware watchdog + systemd service (requires root)").option("--no-determinism", "Skip SSID pinning / power-save changes").option("--no-watchdog", "Skip enabling the systemd hardware watchdog").option("--no-service", "Skip installing the systemd service").action((options) => {
|
|
19313
|
+
if (!requireRoot()) {
|
|
19314
|
+
process.exitCode = 1;
|
|
19315
|
+
return;
|
|
19316
|
+
}
|
|
19317
|
+
const config = readHealConfig();
|
|
19318
|
+
if (!config.preferredSsid) {
|
|
19319
|
+
console.error(source_default.red(`error: set preferredSsid first: machines heal config --set '{"preferredSsid":"X81ND"}'`));
|
|
19320
|
+
process.exitCode = 1;
|
|
19321
|
+
return;
|
|
19322
|
+
}
|
|
19323
|
+
const out = [];
|
|
19324
|
+
if (options.determinism !== false)
|
|
19325
|
+
out.push(...applyDeterminism(config));
|
|
19326
|
+
if (options.watchdog !== false)
|
|
19327
|
+
out.push(...enableHardwareWatchdog());
|
|
19328
|
+
if (options.service !== false)
|
|
19329
|
+
out.push(...installHealService());
|
|
19330
|
+
console.log(renderList("install", out));
|
|
19331
|
+
console.log(source_default.green("self-healing watchdog installed"));
|
|
19332
|
+
});
|
|
19333
|
+
healCommand.command("uninstall").description("Remove the systemd watchdog service (requires root)").action(() => {
|
|
19334
|
+
if (!requireRoot()) {
|
|
19335
|
+
process.exitCode = 1;
|
|
19336
|
+
return;
|
|
19337
|
+
}
|
|
19338
|
+
console.log(renderList("uninstall", uninstallHealService()));
|
|
19339
|
+
});
|
|
18413
19340
|
await program2.parseAsync(process.argv);
|