@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/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: __accessProp.bind(mod, key),
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: __exportSetter.bind(all, name)
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 __accessProp2(key) {
6765
+ function __accessProp(key) {
6784
6766
  return this[key];
6785
6767
  }
6786
- var __toESMCache_node2;
6787
- var __toESMCache_esm2;
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 ? __toESMCache_node2 ??= new WeakMap : __toESMCache_esm2 ??= new WeakMap;
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: __accessProp2.bind(mod, key),
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 __returnValue2 = (v) => v;
6810
- function __exportSetter2(name, newValue) {
6811
- this[name] = __returnValue2.bind(null, newValue);
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: __exportSetter2.bind(all, name)
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 readFileSync8 } from "fs";
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 = readFileSync8(0, "utf8");
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);