@hasna/machines 0.0.46 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -418,6 +418,32 @@ to be scoped to a short-lived approval token, or pass
418
418
  `--trusted-local-mutation` to generate a process-local
419
419
  `HASNA_MACHINES_ALLOW_MUTATIONS=1` prefix for local event recording.
420
420
 
421
+ ## Fleet hostnames (`machines hosts`)
422
+
423
+ Make every fleet machine reachable by its bare name from any other machine —
424
+ `curl http://machine001:3000` works the same on every box — without depending on
425
+ Tailscale MagicDNS being configured. `machines hosts` writes a managed block into
426
+ `/etc/hosts` for each machine in the manifest, choosing the best address:
427
+
428
+ 1. `metadata.lanAddress` from the manifest, when it is on the local machine's `/24`
429
+ 2. the peer's live direct Tailscale LAN endpoint (`CurAddr`) on the local `/24`
430
+ 3. the peer's tailnet IP (`100.64.0.0/10`) — always routable, auto-routed over the
431
+ LAN when co-located
432
+
433
+ ```bash
434
+ machines hosts # dry-run plan (default)
435
+ machines hosts plan -j # JSON plan
436
+ machines hosts apply # write /etc/hosts (uses sudo when the file is root-owned)
437
+ machines hosts plan --no-warm # skip discovering LAN endpoints (faster, tailnet IPs)
438
+ ```
439
+
440
+ By default the command first runs `tailscale ping` against online peers so their
441
+ LAN endpoints become visible and same-LAN machines resolve to their `192.168.x.x`
442
+ address (true LAN-direct) instead of the tailnet IP. Off-LAN or offline peers fall
443
+ back to the tailnet IP. The local machine is skipped. The managed block is delimited
444
+ by markers, so re-running `apply` only rewrites that block and leaves the rest of
445
+ `/etc/hosts` untouched.
446
+
421
447
  ## Dashboard
422
448
 
423
449
  ```bash
@@ -992,7 +992,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
992
992
  this._exitCallback = (err) => {
993
993
  if (err.code !== "commander.executeSubCommandAsync") {
994
994
  throw err;
995
- }
995
+ } else {}
996
996
  };
997
997
  }
998
998
  return this;
package/dist/cli/index.js CHANGED
@@ -993,7 +993,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
993
993
  this._exitCallback = (err) => {
994
994
  if (err.code !== "commander.executeSubCommandAsync") {
995
995
  throw err;
996
- }
996
+ } else {}
997
997
  };
998
998
  }
999
999
  return this;
@@ -2714,7 +2714,7 @@ import {
2714
2714
  sanitizeChannelsForOutput as sanitizeChannelsForOutput2
2715
2715
  } from "@hasna/events";
2716
2716
  import { execFileSync as execFileSync2 } from "child_process";
2717
- import { existsSync as existsSync13, readFileSync as readFileSync13, rmSync as rmSync3 } from "fs";
2717
+ import { existsSync as existsSync14, readFileSync as readFileSync14, rmSync as rmSync3 } from "fs";
2718
2718
  import { join as join12, resolve as resolve4 } from "path";
2719
2719
 
2720
2720
  // node_modules/chalk/source/vendor/ansi-styles/index.js
@@ -9448,6 +9448,326 @@ function renderDomainMapping(domain) {
9448
9448
  };
9449
9449
  }
9450
9450
 
9451
+ // src/commands/hosts.ts
9452
+ import { spawnSync as spawnSync3 } from "child_process";
9453
+ import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
9454
+ import { networkInterfaces, platform as osPlatform } from "os";
9455
+ var HOSTS_BLOCK_BEGIN = "# >>> hasna machines fleet >>>";
9456
+ var HOSTS_BLOCK_END = "# <<< hasna machines fleet <<<";
9457
+ function defaultRunner2(command) {
9458
+ const result = spawnSync3("bash", ["-c", command], { encoding: "utf8", env: process.env });
9459
+ return { stdout: result.stdout || "", exitCode: result.status ?? 1 };
9460
+ }
9461
+ function getHostsPath() {
9462
+ const override = process.env["HASNA_MACHINES_HOSTS_PATH"];
9463
+ if (override)
9464
+ return override;
9465
+ if (osPlatform() === "win32") {
9466
+ return "C:\\Windows\\System32\\drivers\\etc\\hosts";
9467
+ }
9468
+ return "/etc/hosts";
9469
+ }
9470
+ function isIpv4(value) {
9471
+ const parts = value.split(".");
9472
+ if (parts.length !== 4)
9473
+ return false;
9474
+ return parts.every((part) => {
9475
+ if (!/^\d{1,3}$/.test(part))
9476
+ return false;
9477
+ const num = Number(part);
9478
+ return num >= 0 && num <= 255;
9479
+ });
9480
+ }
9481
+ function isPrivateIpv4(value) {
9482
+ if (!isIpv4(value))
9483
+ return false;
9484
+ const [a, b] = value.split(".").map(Number);
9485
+ if (a === 10)
9486
+ return true;
9487
+ if (a === 192 && b === 168)
9488
+ return true;
9489
+ if (a === 172 && b >= 16 && b <= 31)
9490
+ return true;
9491
+ return false;
9492
+ }
9493
+ function isTailnetIpv4(value) {
9494
+ if (!isIpv4(value))
9495
+ return false;
9496
+ const [a, b] = value.split(".").map(Number);
9497
+ return a === 100 && b >= 64 && b <= 127;
9498
+ }
9499
+ function subnet24(value) {
9500
+ if (!isIpv4(value))
9501
+ return null;
9502
+ return value.split(".").slice(0, 3).join(".");
9503
+ }
9504
+ function ipFromEndpoint(endpoint) {
9505
+ if (!endpoint)
9506
+ return null;
9507
+ const host = endpoint.replace(/:\d+$/, "").trim();
9508
+ return isIpv4(host) ? host : null;
9509
+ }
9510
+ function localPrivateSubnets() {
9511
+ const subnets = new Set;
9512
+ const interfaces = networkInterfaces();
9513
+ for (const addrs of Object.values(interfaces)) {
9514
+ for (const addr of addrs ?? []) {
9515
+ if (addr.family !== "IPv4" || addr.internal)
9516
+ continue;
9517
+ if (!isPrivateIpv4(addr.address))
9518
+ continue;
9519
+ const subnet = subnet24(addr.address);
9520
+ if (subnet)
9521
+ subnets.add(subnet);
9522
+ }
9523
+ }
9524
+ return [...subnets];
9525
+ }
9526
+ function shortName(value) {
9527
+ if (!value)
9528
+ return null;
9529
+ const trimmed = value.replace(/\.$/, "").trim();
9530
+ if (!trimmed)
9531
+ return null;
9532
+ return trimmed.split(".")[0]?.toLowerCase() || null;
9533
+ }
9534
+ function machineNames(machine) {
9535
+ const names = [];
9536
+ for (const candidate of [machine.id, machine.hostname, shortName(machine.tailscaleName)]) {
9537
+ const name = shortName(candidate);
9538
+ if (name && !names.includes(name))
9539
+ names.push(name);
9540
+ }
9541
+ return names;
9542
+ }
9543
+ function peerKeysForMachine(machine) {
9544
+ return [
9545
+ machine.id,
9546
+ machine.hostname,
9547
+ shortName(machine.tailscaleName),
9548
+ machine.tailscaleName,
9549
+ machine.sshAddress?.split("@").pop()
9550
+ ].filter((value) => Boolean(value));
9551
+ }
9552
+ function indexPeers(tailscale) {
9553
+ const peers = new Map;
9554
+ if (!tailscale)
9555
+ return peers;
9556
+ const add = (peer) => {
9557
+ if (!peer)
9558
+ return;
9559
+ const id = shortName(peer.HostName) || shortName(peer.DNSName);
9560
+ if (id)
9561
+ peers.set(id, peer);
9562
+ };
9563
+ add(tailscale.Self);
9564
+ for (const peer of Object.values(tailscale.Peer ?? {}))
9565
+ add(peer);
9566
+ return peers;
9567
+ }
9568
+ function findPeer(machine, peers) {
9569
+ for (const key of peerKeysForMachine(machine)) {
9570
+ const peer = peers.get(shortName(key) || key);
9571
+ if (peer)
9572
+ return peer;
9573
+ }
9574
+ return null;
9575
+ }
9576
+ function tailnetIp(peer) {
9577
+ if (!peer)
9578
+ return null;
9579
+ for (const ip of peer.TailscaleIPs ?? []) {
9580
+ if (isTailnetIpv4(ip))
9581
+ return ip;
9582
+ }
9583
+ return null;
9584
+ }
9585
+ function metadataString2(metadata, key) {
9586
+ if (!metadata)
9587
+ return null;
9588
+ const value = metadata[key];
9589
+ return typeof value === "string" && value.trim() ? value.trim() : null;
9590
+ }
9591
+ function buildFleetHostEntries(input) {
9592
+ const { manifest, tailscale, localSubnets, localMachineId } = input;
9593
+ const peers = indexPeers(tailscale);
9594
+ const subnetSet = new Set(localSubnets);
9595
+ const entries = [];
9596
+ const unresolved = [];
9597
+ const warnings = [];
9598
+ for (const machine of manifest.machines) {
9599
+ if (localMachineId && machine.id === localMachineId)
9600
+ continue;
9601
+ const names = machineNames(machine);
9602
+ if (names.length === 0)
9603
+ continue;
9604
+ const peer = findPeer(machine, peers);
9605
+ const lanAddress = metadataString2(machine.metadata, "lanAddress");
9606
+ const explicitIp = metadataString2(machine.metadata, "ipAddress");
9607
+ const curLanIp = ipFromEndpoint(peer?.CurAddr);
9608
+ const tnet = tailnetIp(peer);
9609
+ let ip = null;
9610
+ let source = null;
9611
+ if (lanAddress && isPrivateIpv4(lanAddress) && subnetSet.has(subnet24(lanAddress) ?? "")) {
9612
+ ip = lanAddress;
9613
+ source = "manifest_lan";
9614
+ } else if (curLanIp && isPrivateIpv4(curLanIp) && subnetSet.has(subnet24(curLanIp) ?? "")) {
9615
+ ip = curLanIp;
9616
+ source = "tailscale_lan";
9617
+ } else if (tnet) {
9618
+ ip = tnet;
9619
+ source = "tailscale";
9620
+ } else if (explicitIp && isIpv4(explicitIp)) {
9621
+ ip = explicitIp;
9622
+ source = "manifest_ip";
9623
+ }
9624
+ if (!ip || !source) {
9625
+ unresolved.push(machine.id);
9626
+ continue;
9627
+ }
9628
+ entries.push({ id: machine.id, ip, names, source });
9629
+ }
9630
+ entries.sort((left, right) => left.id.localeCompare(right.id));
9631
+ return { entries, unresolved, warnings };
9632
+ }
9633
+ function renderHostsBlock(entries) {
9634
+ const lines = [
9635
+ HOSTS_BLOCK_BEGIN,
9636
+ "# Managed by `machines hosts apply`. Do not edit between these markers.",
9637
+ ...entries.map((entry) => `${entry.ip} ${entry.names.join(" ")}`),
9638
+ HOSTS_BLOCK_END
9639
+ ];
9640
+ return lines.join(`
9641
+ `);
9642
+ }
9643
+ function mergeHostsContent(existing, block) {
9644
+ const beginIndex = existing.indexOf(HOSTS_BLOCK_BEGIN);
9645
+ const endIndex = existing.indexOf(HOSTS_BLOCK_END);
9646
+ if (beginIndex !== -1 && endIndex !== -1 && endIndex > beginIndex) {
9647
+ const before = existing.slice(0, beginIndex).replace(/\n+$/, "");
9648
+ const after = existing.slice(endIndex + HOSTS_BLOCK_END.length).replace(/^\n+/, "");
9649
+ const head = before ? `${before}
9650
+ ` : "";
9651
+ const tail = after ? `
9652
+ ${after}` : `
9653
+ `;
9654
+ return `${head}${block}${tail}`;
9655
+ }
9656
+ const base = existing.replace(/\n+$/, "");
9657
+ const prefix = base ? `${base}
9658
+
9659
+ ` : "";
9660
+ return `${prefix}${block}
9661
+ `;
9662
+ }
9663
+ function loadTailscaleStatus(runner, binary, warnings) {
9664
+ if (!binary) {
9665
+ if (!warnings.includes("tailscale_not_available"))
9666
+ warnings.push("tailscale_not_available");
9667
+ return null;
9668
+ }
9669
+ const result = runner(`"${binary}" status --json`);
9670
+ if (result.exitCode !== 0) {
9671
+ warnings.push("tailscale_status_failed");
9672
+ return null;
9673
+ }
9674
+ try {
9675
+ return JSON.parse(result.stdout);
9676
+ } catch {
9677
+ warnings.push("tailscale_status_invalid_json");
9678
+ return null;
9679
+ }
9680
+ }
9681
+ function collectPingTargets(tailscale, localSubnets) {
9682
+ if (!tailscale)
9683
+ return [];
9684
+ const subnetSet = new Set(localSubnets);
9685
+ const targets = [];
9686
+ for (const peer of Object.values(tailscale.Peer ?? {})) {
9687
+ if (!peer.Online)
9688
+ continue;
9689
+ const cur = ipFromEndpoint(peer.CurAddr);
9690
+ if (cur && isPrivateIpv4(cur) && subnetSet.has(subnet24(cur) ?? ""))
9691
+ continue;
9692
+ const tnet = tailnetIp(peer);
9693
+ if (tnet && !targets.includes(tnet))
9694
+ targets.push(tnet);
9695
+ }
9696
+ return targets;
9697
+ }
9698
+ var TAILSCALE_CANDIDATES = [
9699
+ "tailscale",
9700
+ "/usr/local/bin/tailscale",
9701
+ "/opt/homebrew/bin/tailscale",
9702
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
9703
+ ];
9704
+ function resolveTailscaleBinary(runner) {
9705
+ for (const candidate of TAILSCALE_CANDIDATES) {
9706
+ const check = candidate.includes("/") ? `test -x "${candidate}"` : `command -v ${candidate} >/dev/null 2>&1`;
9707
+ if (runner(check).exitCode === 0)
9708
+ return candidate;
9709
+ }
9710
+ return null;
9711
+ }
9712
+ function warmDirectPaths(runner, targets, binary, timeoutSeconds = 2) {
9713
+ for (const target of targets) {
9714
+ runner(`"${binary}" ping --c 1 --timeout ${timeoutSeconds}s ${target} >/dev/null 2>&1 || true`);
9715
+ }
9716
+ }
9717
+ function resolveLocalMachineId(tailscale, explicit) {
9718
+ if (explicit)
9719
+ return explicit;
9720
+ return shortName(tailscale?.Self?.HostName) || shortName(process.env["HOSTNAME"]) || null;
9721
+ }
9722
+ function planFleetHosts(options = {}) {
9723
+ const runner = options.runner ?? defaultRunner2;
9724
+ const warnings = [];
9725
+ const binary = resolveTailscaleBinary(runner);
9726
+ let tailscale = loadTailscaleStatus(runner, binary, warnings);
9727
+ const manifest = readManifest();
9728
+ const localSubnets = options.localSubnets ?? localPrivateSubnets();
9729
+ if (options.warm !== false && tailscale && binary && localSubnets.length > 0) {
9730
+ const targets = collectPingTargets(tailscale, localSubnets);
9731
+ if (targets.length > 0) {
9732
+ warmDirectPaths(runner, targets, binary, options.warmTimeoutSeconds);
9733
+ tailscale = loadTailscaleStatus(runner, binary, warnings) ?? tailscale;
9734
+ }
9735
+ }
9736
+ const localMachineId = resolveLocalMachineId(tailscale, options.localMachineId);
9737
+ const built = buildFleetHostEntries({ manifest, tailscale, localSubnets, localMachineId });
9738
+ const block = renderHostsBlock(built.entries);
9739
+ return {
9740
+ hostsPath: getHostsPath(),
9741
+ entries: built.entries,
9742
+ unresolved: built.unresolved,
9743
+ warnings: [...warnings, ...built.warnings],
9744
+ block,
9745
+ localSubnets
9746
+ };
9747
+ }
9748
+ function applyFleetHosts(options = {}) {
9749
+ const plan = planFleetHosts(options);
9750
+ const hostsPath = plan.hostsPath;
9751
+ const existing = existsSync7(hostsPath) ? readFileSync5(hostsPath, "utf8") : "";
9752
+ const merged = mergeHostsContent(existing, plan.block);
9753
+ let viaSudo = false;
9754
+ try {
9755
+ writeFileSync3(hostsPath, merged, "utf8");
9756
+ } catch (error) {
9757
+ const code = error?.code;
9758
+ if (code !== "EACCES" && code !== "EPERM")
9759
+ throw error;
9760
+ const runner = options.runner ?? defaultRunner2;
9761
+ const encoded = Buffer.from(merged, "utf8").toString("base64");
9762
+ const result = runner(`printf %s '${encoded}' | base64 -d | sudo tee ${hostsPath} >/dev/null`);
9763
+ if (result.exitCode !== 0) {
9764
+ throw new Error(`Failed to write ${hostsPath} (need sudo). Re-run with elevated privileges.`);
9765
+ }
9766
+ viaSudo = true;
9767
+ }
9768
+ return { ...plan, written: true, viaSudo };
9769
+ }
9770
+
9451
9771
  // src/commands/diff.ts
9452
9772
  function packageNames(machine) {
9453
9773
  return (machine.packages || []).map((pkg) => pkg.name).sort();
@@ -9801,7 +10121,7 @@ function runTailscaleInstallPlan(plan, options = {}, runner = runMachineCommand)
9801
10121
  }
9802
10122
 
9803
10123
  // src/commands/notifications.ts
9804
- import { accessSync, constants, existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
10124
+ import { accessSync, constants, existsSync as existsSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
9805
10125
  import { delimiter, isAbsolute, join as join7 } from "path";
9806
10126
  init_paths();
9807
10127
  var notificationChannelSchema = exports_external.object({
@@ -9986,10 +10306,10 @@ function getDefaultNotificationConfig() {
9986
10306
  };
9987
10307
  }
9988
10308
  function readNotificationConfig(path = getNotificationsPath()) {
9989
- if (!existsSync7(path)) {
10309
+ if (!existsSync8(path)) {
9990
10310
  return getDefaultNotificationConfig();
9991
10311
  }
9992
- return notificationConfigSchema.parse(JSON.parse(readFileSync5(path, "utf8")));
10312
+ return notificationConfigSchema.parse(JSON.parse(readFileSync6(path, "utf8")));
9993
10313
  }
9994
10314
  function writeNotificationConfig(config, path = getNotificationsPath()) {
9995
10315
  ensureParentDir(path);
@@ -9998,7 +10318,7 @@ function writeNotificationConfig(config, path = getNotificationsPath()) {
9998
10318
  updatedAt: new Date().toISOString(),
9999
10319
  channels: sortChannels(config.channels)
10000
10320
  };
10001
- writeFileSync3(path, `${JSON.stringify(nextConfig, null, 2)}
10321
+ writeFileSync4(path, `${JSON.stringify(nextConfig, null, 2)}
10002
10322
  `, "utf8");
10003
10323
  return nextConfig;
10004
10324
  }
@@ -10087,7 +10407,7 @@ async function testNotificationChannel(channelId, event = "manual.test", message
10087
10407
 
10088
10408
  // src/commands/ports.ts
10089
10409
  init_db();
10090
- import { spawnSync as spawnSync3 } from "child_process";
10410
+ import { spawnSync as spawnSync4 } from "child_process";
10091
10411
  function parseSsOutput(output) {
10092
10412
  return output.trim().split(`
10093
10413
  `).map((line) => line.trim()).filter(Boolean).map((line) => {
@@ -10129,7 +10449,7 @@ function listPorts(machineId) {
10129
10449
  const isLocal = targetMachineId === getLocalMachineId();
10130
10450
  const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
10131
10451
  const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
10132
- const result = spawnSync3("bash", ["-lc", command], { encoding: "utf8" });
10452
+ const result = spawnSync4("bash", ["-lc", command], { encoding: "utf8" });
10133
10453
  if (result.status !== 0) {
10134
10454
  throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
10135
10455
  }
@@ -10141,7 +10461,7 @@ function listPorts(machineId) {
10141
10461
  }
10142
10462
 
10143
10463
  // src/commands/runtime.ts
10144
- import { spawnSync as spawnSync4 } from "child_process";
10464
+ import { spawnSync as spawnSync5 } from "child_process";
10145
10465
  import { setTimeout as sleep } from "timers/promises";
10146
10466
  import { EventsClient } from "@hasna/events";
10147
10467
  function shellQuote5(value) {
@@ -10186,7 +10506,7 @@ function buildTmuxPaneDiedHookPlan(options = {}) {
10186
10506
  }
10187
10507
  function probeTmuxPane(target, tmuxCommand = process.env["HASNA_MACHINES_TMUX_BIN"] || "tmux") {
10188
10508
  const checkedAt = new Date().toISOString();
10189
- const result = spawnSync4(tmuxCommand, ["display-message", "-p", "-t", target, "#{pane_id}"], {
10509
+ const result = spawnSync5(tmuxCommand, ["display-message", "-p", "-t", target, "#{pane_id}"], {
10190
10510
  encoding: "utf8",
10191
10511
  timeout: 5000
10192
10512
  });
@@ -10278,7 +10598,7 @@ function shellQuote6(value) {
10278
10598
  function shellCommand2(command) {
10279
10599
  return command.map(shellQuote6).join(" ");
10280
10600
  }
10281
- function metadataString2(metadata, keys) {
10601
+ function metadataString3(metadata, keys) {
10282
10602
  if (!metadata)
10283
10603
  return null;
10284
10604
  for (const key of keys) {
@@ -10328,8 +10648,8 @@ function resolveScreenCredentials(machineId, options = {}) {
10328
10648
  const screen = resolveScreenTarget(machineId, { ...options, topology });
10329
10649
  const entry = topology.machines.find((machine) => machine.machine_id === screen.machineId);
10330
10650
  const metadata = entry?.metadata;
10331
- const metadataUser = metadataString2(metadata, ["screenUser", "screen_user", "user", "username"]);
10332
- const metadataPasswordSecret = metadataString2(metadata, [
10651
+ const metadataUser = metadataString3(metadata, ["screenUser", "screen_user", "user", "username"]);
10652
+ const metadataPasswordSecret = metadataString3(metadata, [
10333
10653
  "screenPasswordSecret",
10334
10654
  "screen_password_secret",
10335
10655
  "screenVncPasswordSecret",
@@ -10386,7 +10706,7 @@ function buildScreenEnableCommand(machineId, options = {}) {
10386
10706
  }
10387
10707
 
10388
10708
  // src/commands/sync.ts
10389
- import { existsSync as existsSync8, lstatSync, readFileSync as readFileSync6, symlinkSync, copyFileSync } from "fs";
10709
+ import { existsSync as existsSync9, lstatSync, readFileSync as readFileSync7, symlinkSync, copyFileSync } from "fs";
10390
10710
  import { homedir as homedir5 } from "os";
10391
10711
  init_paths();
10392
10712
  init_db();
@@ -10441,15 +10761,15 @@ function detectFileActions(machine) {
10441
10761
  throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
10442
10762
  }
10443
10763
  return (machine.files || []).map((file, index) => {
10444
- const sourceExists = existsSync8(file.source);
10445
- const targetExists = existsSync8(file.target);
10764
+ const sourceExists = existsSync9(file.source);
10765
+ const targetExists = existsSync9(file.target);
10446
10766
  let status = "missing";
10447
10767
  if (sourceExists && targetExists) {
10448
10768
  if (file.mode === "symlink") {
10449
10769
  status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
10450
10770
  } else {
10451
- const source = readFileSync6(file.source, "utf8");
10452
- const target = readFileSync6(file.target, "utf8");
10771
+ const source = readFileSync7(file.source, "utf8");
10772
+ const target = readFileSync7(file.target, "utf8");
10453
10773
  status = source === target ? "ok" : "drifted";
10454
10774
  }
10455
10775
  }
@@ -10802,7 +11122,7 @@ function parseKeyValue(stdout) {
10802
11122
  }
10803
11123
  return result;
10804
11124
  }
10805
- function defaultRunner2(machineId, command) {
11125
+ function defaultRunner3(machineId, command) {
10806
11126
  return runMachineCommand(machineId, command);
10807
11127
  }
10808
11128
  function inspectCommand(machineId, spec, runner) {
@@ -10956,7 +11276,7 @@ function workspaceCheck(machineId, spec, runner) {
10956
11276
  }
10957
11277
  function checkMachineCompatibility(options = {}) {
10958
11278
  const machineId = options.machineId ?? getLocalMachineId();
10959
- const runner = options.runner ?? defaultRunner2;
11279
+ const runner = options.runner ?? defaultRunner3;
10960
11280
  const commands = options.commands ?? DEFAULT_COMMANDS;
10961
11281
  const packages = options.packages ?? defaultPackages();
10962
11282
  const workspaces = options.workspaces ?? [];
@@ -11176,9 +11496,9 @@ function runDoctor(machineId, options = {}) {
11176
11496
 
11177
11497
  // src/commands/daemon.ts
11178
11498
  import { execFileSync } from "child_process";
11179
- import { chmodSync, existsSync as existsSync9, readFileSync as readFileSync7, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync4 } from "fs";
11499
+ import { chmodSync, existsSync as existsSync10, readFileSync as readFileSync8, statSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync5 } from "fs";
11180
11500
  import { delimiter as delimiter2, dirname as dirname4 } from "path";
11181
- import { platform as osPlatform } from "os";
11501
+ import { platform as osPlatform2 } from "os";
11182
11502
  var DEFAULT_SERVICE_NAME = "machines-agent";
11183
11503
  var DEFAULT_EXECUTABLE = "/usr/local/bin/machines-agent";
11184
11504
  var DEFAULT_INTERVAL_MS = 30000;
@@ -11235,7 +11555,7 @@ function runDaemonServicePlan(plan, options = {}) {
11235
11555
  };
11236
11556
  }
11237
11557
  mkdirSync2(dirname4(path), { recursive: true });
11238
- writeFileSync4(path, content, "utf8");
11558
+ writeFileSync5(path, content, "utf8");
11239
11559
  chmodSync(path, Number.parseInt(file.mode, 8));
11240
11560
  filesWritten.push(path);
11241
11561
  }
@@ -11330,7 +11650,7 @@ function normalizeServiceName(value, warnings) {
11330
11650
  return DEFAULT_SERVICE_NAME;
11331
11651
  }
11332
11652
  function normalizePlatform3(value, warnings) {
11333
- const raw = value ?? osPlatform();
11653
+ const raw = value ?? osPlatform2();
11334
11654
  if (raw === "darwin" || raw === "macos")
11335
11655
  return "macos";
11336
11656
  if (raw === "linux")
@@ -11591,7 +11911,7 @@ function bunRuntimeCandidates(executable) {
11591
11911
  }
11592
11912
  function isBunShebangScript(executable) {
11593
11913
  try {
11594
- const content = readFileSync7(executable, "utf8").slice(0, 256);
11914
+ const content = readFileSync8(executable, "utf8").slice(0, 256);
11595
11915
  const firstLine2 = content.split(/\r?\n/, 1)[0] ?? "";
11596
11916
  return /^#!.*\bbun\b/.test(firstLine2);
11597
11917
  } catch {
@@ -11599,7 +11919,7 @@ function isBunShebangScript(executable) {
11599
11919
  }
11600
11920
  }
11601
11921
  function isExecutableFile(path) {
11602
- if (!existsSync9(path))
11922
+ if (!existsSync10(path))
11603
11923
  return false;
11604
11924
  try {
11605
11925
  const stats = statSync(path);
@@ -12222,7 +12542,7 @@ function runSelfTest() {
12222
12542
  // src/commands/clipboard.ts
12223
12543
  init_paths();
12224
12544
  import { createHash as createHash2 } from "crypto";
12225
- import { existsSync as existsSync10, readFileSync as readFileSync8, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
12545
+ import { existsSync as existsSync11, readFileSync as readFileSync9, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
12226
12546
  import { join as join8 } from "path";
12227
12547
  var DEFAULT_CONFIG = {
12228
12548
  version: 1,
@@ -12253,25 +12573,25 @@ function getDefaultConfig() {
12253
12573
  }
12254
12574
  function readConfig(configPath) {
12255
12575
  const path = resolveConfigPath(configPath);
12256
- if (!existsSync10(path)) {
12576
+ if (!existsSync11(path)) {
12257
12577
  return getDefaultConfig();
12258
12578
  }
12259
- const parsed = JSON.parse(readFileSync8(path, "utf8"));
12579
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
12260
12580
  return { ...getDefaultConfig(), ...parsed };
12261
12581
  }
12262
12582
  function writeConfig(config, configPath) {
12263
12583
  const path = resolveConfigPath(configPath);
12264
12584
  ensureParentDir(path);
12265
- writeFileSync5(path, `${JSON.stringify(config, null, 2)}
12585
+ writeFileSync6(path, `${JSON.stringify(config, null, 2)}
12266
12586
  `, "utf8");
12267
12587
  }
12268
12588
  function readHistory(historyPath) {
12269
12589
  const path = resolveHistoryPath(historyPath);
12270
- if (!existsSync10(path)) {
12590
+ if (!existsSync11(path)) {
12271
12591
  return [];
12272
12592
  }
12273
12593
  try {
12274
- return JSON.parse(readFileSync8(path, "utf8"));
12594
+ return JSON.parse(readFileSync9(path, "utf8"));
12275
12595
  } catch {
12276
12596
  return [];
12277
12597
  }
@@ -12279,7 +12599,7 @@ function readHistory(historyPath) {
12279
12599
  function writeHistory(entries, historyPath) {
12280
12600
  const path = resolveHistoryPath(historyPath);
12281
12601
  ensureParentDir(path);
12282
- writeFileSync5(path, `${JSON.stringify(entries, null, 2)}
12602
+ writeFileSync6(path, `${JSON.stringify(entries, null, 2)}
12283
12603
  `, "utf8");
12284
12604
  }
12285
12605
  function computeHash(content) {
@@ -12300,12 +12620,12 @@ function sanitizeClipboardForRead(content, maxSizeBytes, skipPatterns) {
12300
12620
  }
12301
12621
  function getOrCreateClipboardKey() {
12302
12622
  const keyPath = getClipboardKeyPath();
12303
- if (existsSync10(keyPath)) {
12304
- return readFileSync8(keyPath, "utf8").trim();
12623
+ if (existsSync11(keyPath)) {
12624
+ return readFileSync9(keyPath, "utf8").trim();
12305
12625
  }
12306
12626
  const key = createHash2("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
12307
12627
  ensureParentDir(keyPath);
12308
- writeFileSync5(keyPath, `${key}
12628
+ writeFileSync6(keyPath, `${key}
12309
12629
  `, "utf8");
12310
12630
  return key;
12311
12631
  }
@@ -12340,7 +12660,7 @@ function addClipboardEntry(entry, historyPath) {
12340
12660
  }
12341
12661
  function clearClipboardHistory(historyPath) {
12342
12662
  const path = resolveHistoryPath(historyPath);
12343
- if (existsSync10(path)) {
12663
+ if (existsSync11(path)) {
12344
12664
  rmSync2(path);
12345
12665
  }
12346
12666
  }
@@ -12356,7 +12676,7 @@ function getClipboardStatus(historyPath) {
12356
12676
 
12357
12677
  // src/commands/clipboard-daemon.ts
12358
12678
  init_paths();
12359
- import { readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "fs";
12679
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
12360
12680
  import { join as join9 } from "path";
12361
12681
  import { createHash as createHash4 } from "crypto";
12362
12682
 
@@ -12364,7 +12684,7 @@ import { createHash as createHash4 } from "crypto";
12364
12684
  init_paths();
12365
12685
  import { createServer } from "http";
12366
12686
  import { createHash as createHash3 } from "crypto";
12367
- import { readFileSync as readFileSync9 } from "fs";
12687
+ import { readFileSync as readFileSync10 } from "fs";
12368
12688
  function readLocalClipboardSync() {
12369
12689
  const platform5 = process.platform;
12370
12690
  if (platform5 === "darwin") {
@@ -12410,7 +12730,7 @@ function hasCommand3(binary) {
12410
12730
  function loadSharedSecret() {
12411
12731
  const keyPath = getClipboardKeyPath();
12412
12732
  try {
12413
- return readFileSync9(keyPath, "utf8").trim();
12733
+ return readFileSync10(keyPath, "utf8").trim();
12414
12734
  } catch {
12415
12735
  return "";
12416
12736
  }
@@ -12577,18 +12897,18 @@ function computeHash2(content) {
12577
12897
  }
12578
12898
  function loadSharedSecret2() {
12579
12899
  try {
12580
- return readFileSync10(getClipboardKeyPath(), "utf8").trim();
12900
+ return readFileSync11(getClipboardKeyPath(), "utf8").trim();
12581
12901
  } catch {
12582
12902
  return "";
12583
12903
  }
12584
12904
  }
12585
12905
  function writePid(pid) {
12586
- writeFileSync6(DAEMON_PID_PATH, `${pid}
12906
+ writeFileSync7(DAEMON_PID_PATH, `${pid}
12587
12907
  `);
12588
12908
  }
12589
12909
  function readPid() {
12590
12910
  try {
12591
- const pid = Number.parseInt(readFileSync10(DAEMON_PID_PATH, "utf8").trim());
12911
+ const pid = Number.parseInt(readFileSync11(DAEMON_PID_PATH, "utf8").trim());
12592
12912
  return Number.isFinite(pid) ? pid : null;
12593
12913
  } catch {
12594
12914
  return null;
@@ -12689,7 +13009,7 @@ async function discoverPeers() {
12689
13009
 
12690
13010
  // src/commands/heal.ts
12691
13011
  init_paths();
12692
- import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
13012
+ import { existsSync as existsSync12, readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "fs";
12693
13013
  import { join as join10 } from "path";
12694
13014
  var DEFAULT_THRESHOLDS = {
12695
13015
  reconnect: 3,
@@ -12741,9 +13061,9 @@ function getHealStatePath() {
12741
13061
  }
12742
13062
  function readHealConfig(path) {
12743
13063
  const p = path || getHealConfigPath();
12744
- if (!existsSync11(p))
13064
+ if (!existsSync12(p))
12745
13065
  return { ...DEFAULT_HEAL_CONFIG, thresholds: { ...DEFAULT_THRESHOLDS } };
12746
- const parsed = JSON.parse(readFileSync11(p, "utf8"));
13066
+ const parsed = JSON.parse(readFileSync12(p, "utf8"));
12747
13067
  return {
12748
13068
  ...DEFAULT_HEAL_CONFIG,
12749
13069
  ...parsed,
@@ -12754,15 +13074,15 @@ function readHealConfig(path) {
12754
13074
  function writeHealConfig(config, path) {
12755
13075
  const p = path || getHealConfigPath();
12756
13076
  ensureParentDir(p);
12757
- writeFileSync7(p, `${JSON.stringify(config, null, 2)}
13077
+ writeFileSync8(p, `${JSON.stringify(config, null, 2)}
12758
13078
  `, "utf8");
12759
13079
  }
12760
13080
  function readHealState(path) {
12761
13081
  const p = path || getHealStatePath();
12762
- if (!existsSync11(p))
13082
+ if (!existsSync12(p))
12763
13083
  return defaultHealState();
12764
13084
  try {
12765
- return { ...defaultHealState(), ...JSON.parse(readFileSync11(p, "utf8")) };
13085
+ return { ...defaultHealState(), ...JSON.parse(readFileSync12(p, "utf8")) };
12766
13086
  } catch {
12767
13087
  return defaultHealState();
12768
13088
  }
@@ -12770,7 +13090,7 @@ function readHealState(path) {
12770
13090
  function writeHealState(state, path) {
12771
13091
  const p = path || getHealStatePath();
12772
13092
  ensureParentDir(p);
12773
- writeFileSync7(p, `${JSON.stringify(state, null, 2)}
13093
+ writeFileSync8(p, `${JSON.stringify(state, null, 2)}
12774
13094
  `, "utf8");
12775
13095
  }
12776
13096
  function evaluateHealth(probe, config, state) {
@@ -12889,7 +13209,7 @@ function sh(cmd, timeoutMs = 8000) {
12889
13209
  }
12890
13210
  function getCurrentBootId() {
12891
13211
  try {
12892
- return readFileSync11("/proc/sys/kernel/random/boot_id", "utf8").trim();
13212
+ return readFileSync12("/proc/sys/kernel/random/boot_id", "utf8").trim();
12893
13213
  } catch {
12894
13214
  return "";
12895
13215
  }
@@ -12975,7 +13295,7 @@ function executeAction(action, config) {
12975
13295
 
12976
13296
  // src/commands/heal-daemon.ts
12977
13297
  init_paths();
12978
- import { existsSync as existsSync12, readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "fs";
13298
+ import { existsSync as existsSync13, readFileSync as readFileSync13, writeFileSync as writeFileSync9 } from "fs";
12979
13299
  import { join as join11 } from "path";
12980
13300
  var DAEMON_PID_PATH2 = join11(getDataDir(), "heal-daemon.pid");
12981
13301
  var SERVICE_PATH = "/etc/systemd/system/machines-heal.service";
@@ -13018,12 +13338,12 @@ function runHealOnce(config, opts = {}) {
13018
13338
  return result;
13019
13339
  }
13020
13340
  function writePid2(pid) {
13021
- writeFileSync8(DAEMON_PID_PATH2, `${pid}
13341
+ writeFileSync9(DAEMON_PID_PATH2, `${pid}
13022
13342
  `);
13023
13343
  }
13024
13344
  function readPid2() {
13025
13345
  try {
13026
- const pid = Number.parseInt(readFileSync12(DAEMON_PID_PATH2, "utf8").trim());
13346
+ const pid = Number.parseInt(readFileSync13(DAEMON_PID_PATH2, "utf8").trim());
13027
13347
  return Number.isFinite(pid) ? pid : null;
13028
13348
  } catch {
13029
13349
  return null;
@@ -13095,9 +13415,9 @@ function applyDeterminism(config) {
13095
13415
  }
13096
13416
  function enableHardwareWatchdog() {
13097
13417
  const log2 = [];
13098
- if (!existsSync12(SYSTEM_CONF))
13418
+ if (!existsSync13(SYSTEM_CONF))
13099
13419
  return ["/etc/systemd/system.conf not found; skipping hardware watchdog"];
13100
- let conf = readFileSync12(SYSTEM_CONF, "utf8");
13420
+ let conf = readFileSync13(SYSTEM_CONF, "utf8");
13101
13421
  const set = (key, value) => {
13102
13422
  const re = new RegExp(`^#?\\s*${key}=.*$`, "m");
13103
13423
  if (re.test(conf))
@@ -13109,7 +13429,7 @@ ${key}=${value}
13109
13429
  };
13110
13430
  set("RuntimeWatchdogSec", "20s");
13111
13431
  set("RebootWatchdogSec", "2min");
13112
- writeFileSync8(SYSTEM_CONF, conf);
13432
+ writeFileSync9(SYSTEM_CONF, conf);
13113
13433
  sh2("systemctl daemon-reexec");
13114
13434
  log2.push("hardware watchdog: RuntimeWatchdogSec=20s RebootWatchdogSec=2min");
13115
13435
  return log2;
@@ -13127,7 +13447,7 @@ function binPath() {
13127
13447
  candidates.push(`${home}/.bun/bin/machines`);
13128
13448
  candidates.push("/root/.bun/bin/machines", "/usr/local/bin/machines");
13129
13449
  for (const c of candidates) {
13130
- if (c && existsSync12(c))
13450
+ if (c && existsSync13(c))
13131
13451
  return c;
13132
13452
  }
13133
13453
  return "machines";
@@ -13154,7 +13474,7 @@ Environment=PATH=${binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sb
13154
13474
  [Install]
13155
13475
  WantedBy=multi-user.target
13156
13476
  `;
13157
- writeFileSync8(SERVICE_PATH, unit);
13477
+ writeFileSync9(SERVICE_PATH, unit);
13158
13478
  sh2("systemctl daemon-reload");
13159
13479
  sh2("systemctl enable --now machines-heal.service");
13160
13480
  log2.push(`installed + enabled ${SERVICE_PATH} (ExecStart=${exec} heal daemon)`);
@@ -13163,7 +13483,7 @@ WantedBy=multi-user.target
13163
13483
  function uninstallHealService() {
13164
13484
  const log2 = [];
13165
13485
  sh2("systemctl disable --now machines-heal.service 2>/dev/null || true");
13166
- if (existsSync12(SERVICE_PATH)) {
13486
+ if (existsSync13(SERVICE_PATH)) {
13167
13487
  sh2(`rm -f ${SERVICE_PATH}`);
13168
13488
  sh2("systemctl daemon-reload");
13169
13489
  log2.push(`removed ${SERVICE_PATH}`);
@@ -13174,7 +13494,7 @@ function uninstallHealService() {
13174
13494
  }
13175
13495
  function healServiceStatus() {
13176
13496
  return {
13177
- installed: existsSync12(SERVICE_PATH),
13497
+ installed: existsSync13(SERVICE_PATH),
13178
13498
  active: sh2("systemctl is-active machines-heal.service").out === "active",
13179
13499
  enabled: sh2("systemctl is-enabled machines-heal.service 2>/dev/null").out === "enabled"
13180
13500
  };
@@ -13538,9 +13858,9 @@ function withEventStoreScope2(args) {
13538
13858
  return { event_store_dir: eventStoreDir2(), ...args };
13539
13859
  }
13540
13860
  function readJsonArrayFile(path) {
13541
- if (!existsSync13(path))
13861
+ if (!existsSync14(path))
13542
13862
  return [];
13543
- const raw = readFileSync13(path, "utf8").trim();
13863
+ const raw = readFileSync14(path, "utf8").trim();
13544
13864
  if (!raw)
13545
13865
  return [];
13546
13866
  const parsed = JSON.parse(raw);
@@ -13831,7 +14151,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
13831
14151
  console.error("error: --from-stdin requires piped input");
13832
14152
  process.exit(1);
13833
14153
  }
13834
- const input = readFileSync13(0, "utf8");
14154
+ const input = readFileSync14(0, "utf8");
13835
14155
  const machine2 = JSON.parse(input);
13836
14156
  requireCliMutation("manifest_add", typeof options["approvalToken"] === "string" ? options["approvalToken"] : undefined, { machineId: machine2.id, args: machine2 });
13837
14157
  console.log(JSON.stringify(manifestAdd(machine2), null, 2));
@@ -14058,6 +14378,37 @@ dnsCommand.command("list").description("List saved local domain mappings").optio
14058
14378
  dnsCommand.command("render").description("Render hosts/proxy configuration for a domain").argument("<domain>", "Domain name").option("-j, --json", "Print JSON output", false).action((domain) => {
14059
14379
  console.log(JSON.stringify(renderDomainMapping(domain), null, 2));
14060
14380
  });
14381
+ var hostsCommand = program2.command("hosts").description("Sync fleet machine names into /etc/hosts so machine<NN>:port resolves on the LAN and tailnet");
14382
+ function printHostsResult(plan, applied, viaSudo = false) {
14383
+ console.log(`hosts file: ${plan.hostsPath}`);
14384
+ console.log(`local subnets: ${plan.localSubnets.join(", ") || "none"}`);
14385
+ for (const entry of plan.entries) {
14386
+ console.log(` ${entry.ip} ${entry.names.join(" ")} (${entry.source})`);
14387
+ }
14388
+ if (plan.unresolved.length > 0) {
14389
+ console.log(`unresolved: ${plan.unresolved.join(", ")}`);
14390
+ }
14391
+ if (plan.warnings.length > 0) {
14392
+ console.log(`warnings: ${plan.warnings.join(", ")}`);
14393
+ }
14394
+ console.log(applied ? `applied ${plan.entries.length} entries${viaSudo ? " (via sudo)" : ""}` : "dry run \u2014 re-run `machines hosts apply` to write");
14395
+ }
14396
+ hostsCommand.command("plan", { isDefault: true }).description("Preview the managed /etc/hosts block for the fleet (dry run)").option("-j, --json", "Print JSON output", false).option("--no-warm", "Skip establishing direct Tailscale paths to discover LAN endpoints").action((options) => {
14397
+ const plan = planFleetHosts({ warm: options.warm });
14398
+ if (options.json) {
14399
+ console.log(JSON.stringify(plan, null, 2));
14400
+ return;
14401
+ }
14402
+ printHostsResult(plan, false);
14403
+ });
14404
+ hostsCommand.command("apply").description("Write the managed fleet block into /etc/hosts (uses sudo when required)").option("-j, --json", "Print JSON output", false).option("--no-warm", "Skip establishing direct Tailscale paths to discover LAN endpoints").action((options) => {
14405
+ const result = applyFleetHosts({ warm: options.warm });
14406
+ if (options.json) {
14407
+ console.log(JSON.stringify(result, null, 2));
14408
+ return;
14409
+ }
14410
+ printHostsResult(result, true, result.viaSudo);
14411
+ });
14061
14412
  notificationsCommand.command("add").description("Add or replace a notification channel").requiredOption("--id <id>", "Channel identifier").requiredOption("--type <type>", "email | webhook | command").requiredOption("--target <target>", "Email, webhook URL, or command executable").option("--arg <arg...>", "Command argument for command transports", collectOptionValues, []).option("--event <event...>", "Events routed to this channel", ["setup_failed", "sync_failed"]).option("--disabled", "Create the channel in disabled state", false).option("--approval-token <token>", "Operator mutation approval token for command transports").option("-j, --json", "Print JSON output", false).action((options) => {
14062
14413
  const enabled = !options.disabled;
14063
14414
  const events = [...new Set(options.event)];
@@ -0,0 +1,82 @@
1
+ import type { FleetManifest } from "../types.js";
2
+ export declare const HOSTS_BLOCK_BEGIN = "# >>> hasna machines fleet >>>";
3
+ export declare const HOSTS_BLOCK_END = "# <<< hasna machines fleet <<<";
4
+ /**
5
+ * Where each fleet host entry's IP came from, in descending preference:
6
+ * - manifest_lan : a `metadata.lanAddress` on the same /24 as this machine
7
+ * - tailscale_lan : the peer's live direct LAN endpoint (CurAddr) on this /24
8
+ * - tailscale : the peer's tailnet (100.64.0.0/10) IP — always routable
9
+ * - manifest_ip : an explicit `metadata.ipAddress` override
10
+ */
11
+ export type HostEntrySource = "manifest_lan" | "tailscale_lan" | "tailscale" | "manifest_ip";
12
+ export interface FleetHostEntry {
13
+ id: string;
14
+ ip: string;
15
+ names: string[];
16
+ source: HostEntrySource;
17
+ }
18
+ export interface RawTailscalePeer {
19
+ HostName?: string;
20
+ DNSName?: string;
21
+ TailscaleIPs?: string[];
22
+ CurAddr?: string;
23
+ Online?: boolean;
24
+ }
25
+ export interface RawTailscaleStatus {
26
+ Self?: RawTailscalePeer;
27
+ Peer?: Record<string, RawTailscalePeer>;
28
+ }
29
+ export interface HostsCommandResult {
30
+ stdout: string;
31
+ exitCode: number;
32
+ }
33
+ export type HostsCommandRunner = (command: string) => HostsCommandResult;
34
+ export interface BuildFleetHostEntriesInput {
35
+ manifest: FleetManifest;
36
+ tailscale: RawTailscaleStatus | null;
37
+ localSubnets: string[];
38
+ localMachineId?: string | null;
39
+ }
40
+ export interface BuildFleetHostEntriesResult {
41
+ entries: FleetHostEntry[];
42
+ unresolved: string[];
43
+ warnings: string[];
44
+ }
45
+ export interface FleetHostsPlan extends BuildFleetHostEntriesResult {
46
+ hostsPath: string;
47
+ block: string;
48
+ localSubnets: string[];
49
+ }
50
+ export interface FleetHostsOptions {
51
+ runner?: HostsCommandRunner;
52
+ localSubnets?: string[];
53
+ localMachineId?: string | null;
54
+ /**
55
+ * Establish direct Tailscale paths to online peers first so their LAN
56
+ * endpoints (CurAddr) become visible and can be preferred over tailnet IPs.
57
+ * Defaults to true.
58
+ */
59
+ warm?: boolean;
60
+ warmTimeoutSeconds?: number;
61
+ }
62
+ export interface ApplyFleetHostsResult extends FleetHostsPlan {
63
+ written: boolean;
64
+ viaSudo: boolean;
65
+ }
66
+ export declare function getHostsPath(): string;
67
+ export declare function isPrivateIpv4(value: string): boolean;
68
+ export declare function subnet24(value: string): string | null;
69
+ export declare function localPrivateSubnets(): string[];
70
+ export declare function buildFleetHostEntries(input: BuildFleetHostEntriesInput): BuildFleetHostEntriesResult;
71
+ export declare function renderHostsBlock(entries: FleetHostEntry[]): string;
72
+ export declare function mergeHostsContent(existing: string, block: string): string;
73
+ /**
74
+ * Online peers that do not yet expose a same-subnet LAN endpoint but do have a
75
+ * tailnet IP — pinging these establishes a direct path so their LAN address
76
+ * becomes resolvable.
77
+ */
78
+ export declare function collectPingTargets(tailscale: RawTailscaleStatus | null, localSubnets: string[]): string[];
79
+ export declare function resolveTailscaleBinary(runner: HostsCommandRunner): string | null;
80
+ export declare function warmDirectPaths(runner: HostsCommandRunner, targets: string[], binary: string, timeoutSeconds?: number): void;
81
+ export declare function planFleetHosts(options?: FleetHostsOptions): FleetHostsPlan;
82
+ export declare function applyFleetHosts(options?: FleetHostsOptions): ApplyFleetHostsResult;
package/dist/index.js CHANGED
@@ -19926,7 +19926,7 @@ class JSONSchemaGenerator {
19926
19926
  if (val === undefined) {
19927
19927
  if (this.unrepresentable === "throw") {
19928
19928
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
19929
- }
19929
+ } else {}
19930
19930
  } else if (typeof val === "bigint") {
19931
19931
  if (this.unrepresentable === "throw") {
19932
19932
  throw new Error("BigInt literals cannot be represented in JSON Schema");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/machines",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "description": "Machine fleet management CLI + MCP for developers",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",