@hasna/machines 0.0.1 → 0.0.3

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
@@ -2586,13 +2586,17 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
2586
2586
  var source_default = chalk;
2587
2587
 
2588
2588
  // src/version.ts
2589
- import { readFileSync } from "fs";
2589
+ import { existsSync, readFileSync } from "fs";
2590
2590
  import { dirname, join } from "path";
2591
2591
  import { fileURLToPath } from "url";
2592
2592
  function getPackageVersion() {
2593
2593
  try {
2594
2594
  const here = dirname(fileURLToPath(import.meta.url));
2595
- const pkgPath = join(here, "..", "package.json");
2595
+ const candidates = [join(here, "..", "package.json"), join(here, "..", "..", "package.json")];
2596
+ const pkgPath = candidates.find((candidate) => existsSync(candidate));
2597
+ if (!pkgPath) {
2598
+ return "0.0.0";
2599
+ }
2596
2600
  return JSON.parse(readFileSync(pkgPath, "utf8")).version || "0.0.0";
2597
2601
  } catch {
2598
2602
  return "0.0.0";
@@ -2600,7 +2604,7 @@ function getPackageVersion() {
2600
2604
  }
2601
2605
 
2602
2606
  // src/manifests.ts
2603
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
2607
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync } from "fs";
2604
2608
  import { arch, homedir, hostname, platform, userInfo } from "os";
2605
2609
  import { dirname as dirname3 } from "path";
2606
2610
 
@@ -6578,7 +6582,7 @@ var coerce = {
6578
6582
  };
6579
6583
  var NEVER = INVALID;
6580
6584
  // src/paths.ts
6581
- import { existsSync, mkdirSync } from "fs";
6585
+ import { existsSync as existsSync2, mkdirSync } from "fs";
6582
6586
  import { dirname as dirname2, join as join2, resolve } from "path";
6583
6587
  function homeDir() {
6584
6588
  return process.env["HOME"] || process.env["USERPROFILE"] || "~";
@@ -6599,7 +6603,7 @@ function ensureParentDir(filePath) {
6599
6603
  if (filePath === ":memory:")
6600
6604
  return;
6601
6605
  const dir = dirname2(resolve(filePath));
6602
- if (!existsSync(dir)) {
6606
+ if (!existsSync2(dir)) {
6603
6607
  mkdirSync(dir, { recursive: true });
6604
6608
  }
6605
6609
  }
@@ -6664,7 +6668,7 @@ function getDefaultManifest() {
6664
6668
  };
6665
6669
  }
6666
6670
  function readManifest(path = getManifestPath()) {
6667
- if (!existsSync2(path)) {
6671
+ if (!existsSync3(path)) {
6668
6672
  return getDefaultManifest();
6669
6673
  }
6670
6674
  const raw = JSON.parse(readFileSync2(path, "utf8"));
@@ -6748,7 +6752,7 @@ import { hostname as hostname2 } from "os";
6748
6752
  import { createRequire } from "module";
6749
6753
  import { Database } from "bun:sqlite";
6750
6754
  import {
6751
- existsSync as existsSync3,
6755
+ existsSync as existsSync4,
6752
6756
  mkdirSync as mkdirSync2,
6753
6757
  readdirSync,
6754
6758
  copyFileSync
@@ -16610,14 +16614,14 @@ function runCertPlan(domains, options = {}) {
16610
16614
  }
16611
16615
 
16612
16616
  // src/commands/dns.ts
16613
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
16617
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
16614
16618
  import { join as join8 } from "path";
16615
16619
  function getDnsPath() {
16616
16620
  return join8(getDataDir(), "dns.json");
16617
16621
  }
16618
16622
  function readMappings() {
16619
16623
  const path = getDnsPath();
16620
- if (!existsSync4(path))
16624
+ if (!existsSync5(path))
16621
16625
  return [];
16622
16626
  return JSON.parse(readFileSync4(path, "utf8"));
16623
16627
  }
@@ -16694,17 +16698,95 @@ function diffMachines(leftMachineId, rightMachineId) {
16694
16698
  };
16695
16699
  }
16696
16700
 
16701
+ // src/remote.ts
16702
+ import { spawnSync as spawnSync2 } from "child_process";
16703
+
16704
+ // src/commands/ssh.ts
16705
+ import { spawnSync } from "child_process";
16706
+ function envReachableHosts() {
16707
+ const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
16708
+ return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
16709
+ }
16710
+ function isReachable(host) {
16711
+ const overrides = envReachableHosts();
16712
+ if (overrides.size > 0) {
16713
+ return overrides.has(host);
16714
+ }
16715
+ const probe = spawnSync("bash", ["-lc", `getent hosts ${host} >/dev/null 2>&1 || ping -c 1 -W 1 ${host} >/dev/null 2>&1`], {
16716
+ stdio: "ignore"
16717
+ });
16718
+ return probe.status === 0;
16719
+ }
16720
+ function resolveSshTarget(machineId) {
16721
+ const machine = getManifestMachine(machineId);
16722
+ if (!machine) {
16723
+ throw new Error(`Machine not found in manifest: ${machineId}`);
16724
+ }
16725
+ const current = detectCurrentMachineManifest();
16726
+ if (machine.id === current.id) {
16727
+ return {
16728
+ machineId,
16729
+ target: "localhost",
16730
+ route: "local"
16731
+ };
16732
+ }
16733
+ const lanTarget = machine.sshAddress || machine.hostname || machine.id;
16734
+ const tailscaleTarget = machine.tailscaleName || machine.hostname || machine.id;
16735
+ const route = isReachable(lanTarget) ? "lan" : "tailscale";
16736
+ return {
16737
+ machineId,
16738
+ target: route === "lan" ? lanTarget : tailscaleTarget,
16739
+ route
16740
+ };
16741
+ }
16742
+ function buildSshCommand(machineId, remoteCommand) {
16743
+ const resolved = resolveSshTarget(machineId);
16744
+ return remoteCommand ? `ssh ${resolved.target} ${JSON.stringify(remoteCommand)}` : `ssh ${resolved.target}`;
16745
+ }
16746
+
16747
+ // src/remote.ts
16748
+ function runMachineCommand(machineId, command) {
16749
+ const localMachineId = getLocalMachineId();
16750
+ const isLocal = machineId === localMachineId;
16751
+ const route = isLocal ? "local" : resolveSshTarget(machineId).route;
16752
+ const shellCommand = isLocal ? command : buildSshCommand(machineId, command);
16753
+ const result = spawnSync2("bash", ["-lc", shellCommand], {
16754
+ encoding: "utf8",
16755
+ env: process.env
16756
+ });
16757
+ return {
16758
+ machineId,
16759
+ source: route,
16760
+ stdout: result.stdout || "",
16761
+ stderr: result.stderr || "",
16762
+ exitCode: result.status ?? 1
16763
+ };
16764
+ }
16765
+
16697
16766
  // src/commands/apps.ts
16698
16767
  function getPackageName(app) {
16699
16768
  return app.packageName || app.name;
16700
16769
  }
16770
+ function getAppManager(machine, app) {
16771
+ if (app.manager)
16772
+ return app.manager;
16773
+ if (machine.platform === "macos")
16774
+ return "brew";
16775
+ if (machine.platform === "windows")
16776
+ return "winget";
16777
+ return "apt";
16778
+ }
16779
+ function shellQuote(value) {
16780
+ return `'${value.replace(/'/g, `'\\''`)}'`;
16781
+ }
16701
16782
  function buildAppCommand(machine, app) {
16702
16783
  const packageName = getPackageName(app);
16703
- if (app.manager === "custom") {
16784
+ const manager = getAppManager(machine, app);
16785
+ if (manager === "custom") {
16704
16786
  return packageName;
16705
16787
  }
16706
16788
  if (machine.platform === "macos") {
16707
- if (app.manager === "cask") {
16789
+ if (manager === "cask") {
16708
16790
  return `brew install --cask ${packageName}`;
16709
16791
  }
16710
16792
  return `brew install ${packageName}`;
@@ -16714,18 +16796,48 @@ function buildAppCommand(machine, app) {
16714
16796
  }
16715
16797
  return `sudo apt-get install -y ${packageName}`;
16716
16798
  }
16799
+ function buildAppProbeCommand(machine, app) {
16800
+ const packageName = shellQuote(getPackageName(app));
16801
+ const manager = getAppManager(machine, app);
16802
+ if (manager === "custom") {
16803
+ return `if command -v ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion=custom\\n'; else printf 'installed=0\\n'; fi`;
16804
+ }
16805
+ if (machine.platform === "macos") {
16806
+ if (manager === "cask") {
16807
+ return `if brew list --cask ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion=installed\\n'; else printf 'installed=0\\n'; fi`;
16808
+ }
16809
+ return `if brew list --versions ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion='; brew list --versions ${packageName} | awk '{print $2}'; printf '\\n'; else printf 'installed=0\\n'; fi`;
16810
+ }
16811
+ if (machine.platform === "windows") {
16812
+ return `if winget list --id ${packageName} --exact >/dev/null 2>&1; then printf 'installed=1\\nversion=installed\\n'; else printf 'installed=0\\n'; fi`;
16813
+ }
16814
+ return `if dpkg-query -W -f='${"${Version}"}' ${packageName} >/tmp/machines-app-version 2>/dev/null; then printf 'installed=1\\nversion='; cat /tmp/machines-app-version; printf '\\n'; rm -f /tmp/machines-app-version; else printf 'installed=0\\n'; fi`;
16815
+ }
16717
16816
  function buildAppSteps(machine) {
16718
16817
  return (machine.apps || []).map((app) => ({
16719
16818
  id: `app-${app.name}`,
16720
16819
  title: `Install ${app.name} on ${machine.id}`,
16721
16820
  command: buildAppCommand(machine, app),
16722
- manager: app.manager === "custom" ? "custom" : machine.platform === "macos" ? "brew" : machine.platform === "windows" ? "custom" : "apt",
16821
+ manager: getAppManager(machine, app) === "custom" ? "custom" : machine.platform === "macos" ? "brew" : machine.platform === "windows" ? "custom" : "apt",
16723
16822
  privileged: machine.platform === "linux"
16724
16823
  }));
16725
16824
  }
16726
16825
  function resolveMachine(machineId) {
16727
16826
  return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
16728
16827
  }
16828
+ function parseProbeOutput(app, machine, stdout) {
16829
+ const lines = stdout.trim().split(`
16830
+ `).filter(Boolean);
16831
+ const installedLine = lines.find((line) => line.startsWith("installed="));
16832
+ const versionLine = lines.find((line) => line.startsWith("version="));
16833
+ return {
16834
+ name: app.name,
16835
+ packageName: getPackageName(app),
16836
+ manager: getAppManager(machine, app),
16837
+ installed: installedLine === "installed=1",
16838
+ version: versionLine?.slice("version=".length) || undefined
16839
+ };
16840
+ }
16729
16841
  function listApps(machineId) {
16730
16842
  const machine = resolveMachine(machineId);
16731
16843
  return {
@@ -16742,6 +16854,26 @@ function buildAppsPlan(machineId) {
16742
16854
  executed: 0
16743
16855
  };
16744
16856
  }
16857
+ function getAppsStatus(machineId) {
16858
+ const machine = resolveMachine(machineId);
16859
+ const apps = (machine.apps || []).map((app) => {
16860
+ const probe = runMachineCommand(machine.id, buildAppProbeCommand(machine, app));
16861
+ return parseProbeOutput(app, machine, probe.stdout);
16862
+ });
16863
+ return {
16864
+ machineId: machine.id,
16865
+ source: apps.length > 0 ? runMachineCommand(machine.id, "true").source : machine.id === detectCurrentMachineManifest().id ? "local" : runMachineCommand(machine.id, "true").source,
16866
+ apps
16867
+ };
16868
+ }
16869
+ function diffApps(machineId) {
16870
+ const status = getAppsStatus(machineId);
16871
+ return {
16872
+ ...status,
16873
+ missing: status.apps.filter((app) => !app.installed).map((app) => app.name),
16874
+ installed: status.apps.filter((app) => app.installed).map((app) => app.name)
16875
+ };
16876
+ }
16745
16877
  function runAppsInstall(machineId, options = {}) {
16746
16878
  const plan = buildAppsPlan(machineId);
16747
16879
  if (!options.apply)
@@ -16775,6 +16907,13 @@ var AI_CLI_PACKAGES = {
16775
16907
  codex: "@openai/codex",
16776
16908
  gemini: "@google/gemini-cli"
16777
16909
  };
16910
+ function getToolBinary(tool) {
16911
+ if (tool === "claude")
16912
+ return process.env["HASNA_MACHINES_CLAUDE_BINARY"] || "claude";
16913
+ if (tool === "codex")
16914
+ return process.env["HASNA_MACHINES_CODEX_BINARY"] || "codex";
16915
+ return process.env["HASNA_MACHINES_GEMINI_BINARY"] || "gemini";
16916
+ }
16778
16917
  function normalizeTools(tools) {
16779
16918
  if (!tools || tools.length === 0) {
16780
16919
  return ["claude", "codex", "gemini"];
@@ -16794,8 +16933,27 @@ function buildInstallSteps(machine, tools) {
16794
16933
  manager: "bun"
16795
16934
  }));
16796
16935
  }
16936
+ function resolveMachine2(machineId) {
16937
+ return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
16938
+ }
16939
+ function buildProbeCommand(tool) {
16940
+ const binary = getToolBinary(tool);
16941
+ return `if command -v ${binary} >/dev/null 2>&1; then printf 'installed=1\\nversion='; ${binary} --version 2>/dev/null | head -n 1; printf '\\n'; else printf 'installed=0\\n'; fi`;
16942
+ }
16943
+ function parseProbe(tool, stdout) {
16944
+ const lines = stdout.trim().split(`
16945
+ `).filter(Boolean);
16946
+ const installedLine = lines.find((line) => line.startsWith("installed="));
16947
+ const versionLine = lines.find((line) => line.startsWith("version="));
16948
+ return {
16949
+ tool,
16950
+ packageName: AI_CLI_PACKAGES[tool],
16951
+ installed: installedLine === "installed=1",
16952
+ version: versionLine?.slice("version=".length) || undefined
16953
+ };
16954
+ }
16797
16955
  function buildClaudeInstallPlan(machineId, tools) {
16798
- const machine = (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
16956
+ const machine = resolveMachine2(machineId);
16799
16957
  return {
16800
16958
  machineId: machine.id,
16801
16959
  mode: "plan",
@@ -16803,6 +16961,24 @@ function buildClaudeInstallPlan(machineId, tools) {
16803
16961
  executed: 0
16804
16962
  };
16805
16963
  }
16964
+ function getClaudeCliStatus(machineId, tools) {
16965
+ const machine = resolveMachine2(machineId);
16966
+ const normalizedTools = normalizeTools(tools);
16967
+ const route = runMachineCommand(machine.id, "true").source;
16968
+ return {
16969
+ machineId: machine.id,
16970
+ source: route,
16971
+ tools: normalizedTools.map((tool) => parseProbe(tool, runMachineCommand(machine.id, buildProbeCommand(tool)).stdout))
16972
+ };
16973
+ }
16974
+ function diffClaudeCli(machineId, tools) {
16975
+ const status = getClaudeCliStatus(machineId, tools);
16976
+ return {
16977
+ ...status,
16978
+ missing: status.tools.filter((tool) => !tool.installed).map((tool) => tool.tool),
16979
+ installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
16980
+ };
16981
+ }
16806
16982
  function runClaudeInstall(machineId, tools, options = {}) {
16807
16983
  const plan = buildClaudeInstallPlan(machineId, tools);
16808
16984
  if (!options.apply)
@@ -16899,7 +17075,7 @@ function runTailscaleInstall(machineId, options = {}) {
16899
17075
  }
16900
17076
 
16901
17077
  // src/commands/notifications.ts
16902
- import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
17078
+ import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
16903
17079
  var notificationChannelSchema = exports_external.object({
16904
17080
  id: exports_external.string(),
16905
17081
  type: exports_external.enum(["email", "webhook", "command"]),
@@ -16915,6 +17091,138 @@ var notificationConfigSchema = exports_external.object({
16915
17091
  function sortChannels(channels) {
16916
17092
  return [...channels].sort((left, right) => left.id.localeCompare(right.id));
16917
17093
  }
17094
+ function shellQuote2(value) {
17095
+ return `'${value.replace(/'/g, `'\\''`)}'`;
17096
+ }
17097
+ function hasCommand(binary) {
17098
+ const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], {
17099
+ stdout: "ignore",
17100
+ stderr: "ignore",
17101
+ env: process.env
17102
+ });
17103
+ return result.exitCode === 0;
17104
+ }
17105
+ function buildNotificationPreview(channel, event, message) {
17106
+ if (channel.type === "email") {
17107
+ return `send email to ${channel.target}: [${event}] ${message}`;
17108
+ }
17109
+ if (channel.type === "webhook") {
17110
+ return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
17111
+ }
17112
+ return `${channel.target} --event ${event} --message ${JSON.stringify(message)}`;
17113
+ }
17114
+ async function dispatchEmail(channel, event, message) {
17115
+ const subject = `[${event}] machines notification`;
17116
+ const body = `To: ${channel.target}
17117
+ Subject: ${subject}
17118
+ Content-Type: text/plain; charset=utf-8
17119
+
17120
+ ${message}
17121
+ `;
17122
+ if (hasCommand("sendmail")) {
17123
+ const result = Bun.spawnSync(["bash", "-lc", "sendmail -t"], {
17124
+ stdin: new TextEncoder().encode(body),
17125
+ stdout: "pipe",
17126
+ stderr: "pipe",
17127
+ env: process.env
17128
+ });
17129
+ if (result.exitCode !== 0) {
17130
+ throw new Error(result.stderr.toString().trim() || `sendmail exited with ${result.exitCode}`);
17131
+ }
17132
+ return {
17133
+ channelId: channel.id,
17134
+ event,
17135
+ delivered: true,
17136
+ transport: channel.type,
17137
+ detail: `Delivered via sendmail to ${channel.target}`
17138
+ };
17139
+ }
17140
+ if (hasCommand("mail")) {
17141
+ const command = `printf %s ${shellQuote2(message)} | mail -s ${shellQuote2(subject)} ${shellQuote2(channel.target)}`;
17142
+ const result = Bun.spawnSync(["bash", "-lc", command], {
17143
+ stdout: "pipe",
17144
+ stderr: "pipe",
17145
+ env: process.env
17146
+ });
17147
+ if (result.exitCode !== 0) {
17148
+ throw new Error(result.stderr.toString().trim() || `mail exited with ${result.exitCode}`);
17149
+ }
17150
+ return {
17151
+ channelId: channel.id,
17152
+ event,
17153
+ delivered: true,
17154
+ transport: channel.type,
17155
+ detail: `Delivered via mail to ${channel.target}`
17156
+ };
17157
+ }
17158
+ throw new Error("No local email transport available. Install sendmail or mail.");
17159
+ }
17160
+ async function dispatchWebhook(channel, event, message) {
17161
+ const response = await fetch(channel.target, {
17162
+ method: "POST",
17163
+ headers: {
17164
+ "content-type": "application/json"
17165
+ },
17166
+ body: JSON.stringify({
17167
+ channelId: channel.id,
17168
+ event,
17169
+ message,
17170
+ sentAt: new Date().toISOString()
17171
+ })
17172
+ });
17173
+ if (!response.ok) {
17174
+ const text = await response.text();
17175
+ throw new Error(`Webhook responded ${response.status}: ${text || response.statusText}`);
17176
+ }
17177
+ return {
17178
+ channelId: channel.id,
17179
+ event,
17180
+ delivered: true,
17181
+ transport: channel.type,
17182
+ detail: `Webhook accepted with HTTP ${response.status}`
17183
+ };
17184
+ }
17185
+ async function dispatchCommand(channel, event, message) {
17186
+ const result = Bun.spawnSync(["bash", "-lc", channel.target], {
17187
+ stdout: "pipe",
17188
+ stderr: "pipe",
17189
+ env: {
17190
+ ...process.env,
17191
+ HASNA_MACHINES_NOTIFICATION_CHANNEL: channel.id,
17192
+ HASNA_MACHINES_NOTIFICATION_EVENT: event,
17193
+ HASNA_MACHINES_NOTIFICATION_MESSAGE: message
17194
+ }
17195
+ });
17196
+ if (result.exitCode !== 0) {
17197
+ throw new Error(result.stderr.toString().trim() || `command exited with ${result.exitCode}`);
17198
+ }
17199
+ const stdout = result.stdout.toString().trim();
17200
+ return {
17201
+ channelId: channel.id,
17202
+ event,
17203
+ delivered: true,
17204
+ transport: channel.type,
17205
+ detail: stdout || "Command completed successfully"
17206
+ };
17207
+ }
17208
+ async function dispatchChannel(channel, event, message) {
17209
+ if (!channel.enabled) {
17210
+ return {
17211
+ channelId: channel.id,
17212
+ event,
17213
+ delivered: false,
17214
+ transport: channel.type,
17215
+ detail: "Channel is disabled"
17216
+ };
17217
+ }
17218
+ if (channel.type === "email") {
17219
+ return dispatchEmail(channel, event, message);
17220
+ }
17221
+ if (channel.type === "webhook") {
17222
+ return dispatchWebhook(channel, event, message);
17223
+ }
17224
+ return dispatchCommand(channel, event, message);
17225
+ }
16918
17226
  function getDefaultNotificationConfig() {
16919
17227
  return {
16920
17228
  version: 1,
@@ -16923,7 +17231,7 @@ function getDefaultNotificationConfig() {
16923
17231
  };
16924
17232
  }
16925
17233
  function readNotificationConfig(path = getNotificationsPath()) {
16926
- if (!existsSync5(path)) {
17234
+ if (!existsSync6(path)) {
16927
17235
  return getDefaultNotificationConfig();
16928
17236
  }
16929
17237
  return notificationConfigSchema.parse(JSON.parse(readFileSync5(path, "utf8")));
@@ -16958,16 +17266,34 @@ function removeNotificationChannel(channelId) {
16958
17266
  channels: config.channels.filter((channel) => channel.id !== channelId)
16959
17267
  });
16960
17268
  }
16961
- function buildNotificationPreview(channel, event, message) {
16962
- if (channel.type === "email") {
16963
- return `send email to ${channel.target}: [${event}] ${message}`;
16964
- }
16965
- if (channel.type === "webhook") {
16966
- return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
17269
+ async function dispatchNotificationEvent(event, message, options = {}) {
17270
+ const channels = readNotificationConfig().channels.filter((channel) => {
17271
+ if (options.channelId && channel.id !== options.channelId) {
17272
+ return false;
17273
+ }
17274
+ return channel.events.includes(event) || event === "manual.test";
17275
+ });
17276
+ const deliveries = [];
17277
+ for (const channel of channels) {
17278
+ try {
17279
+ deliveries.push(await dispatchChannel(channel, event, message));
17280
+ } catch (error) {
17281
+ deliveries.push({
17282
+ channelId: channel.id,
17283
+ event,
17284
+ delivered: false,
17285
+ transport: channel.type,
17286
+ detail: error instanceof Error ? error.message : String(error)
17287
+ });
17288
+ }
16967
17289
  }
16968
- return `${channel.target} --event ${event} --message ${JSON.stringify(message)}`;
17290
+ return {
17291
+ event,
17292
+ message,
17293
+ deliveries
17294
+ };
16969
17295
  }
16970
- function testNotificationChannel(channelId, event = "manual.test", message = "machines notification test", options = {}) {
17296
+ async function testNotificationChannel(channelId, event = "manual.test", message = "machines notification test", options = {}) {
16971
17297
  const channel = readNotificationConfig().channels.find((entry) => entry.id === channelId);
16972
17298
  if (!channel) {
16973
17299
  throw new Error(`Notification channel not found: ${channelId}`);
@@ -16978,77 +17304,25 @@ function testNotificationChannel(channelId, event = "manual.test", message = "ma
16978
17304
  channelId,
16979
17305
  mode: "plan",
16980
17306
  delivered: false,
16981
- preview
17307
+ preview,
17308
+ detail: "Preview only"
16982
17309
  };
16983
17310
  }
16984
17311
  if (!options.yes) {
16985
17312
  throw new Error("Notification test execution requires --yes.");
16986
17313
  }
16987
- if (channel.type === "command") {
16988
- const result = Bun.spawnSync(["bash", "-lc", preview], {
16989
- stdout: "pipe",
16990
- stderr: "pipe",
16991
- env: process.env
16992
- });
16993
- if (result.exitCode !== 0) {
16994
- throw new Error(`Notification command failed (${channel.id}): ${result.stderr.toString().trim()}`);
16995
- }
16996
- }
17314
+ const delivery = await dispatchChannel(channel, event, message);
16997
17315
  return {
16998
17316
  channelId,
16999
17317
  mode: "apply",
17000
- delivered: channel.enabled,
17001
- preview
17002
- };
17003
- }
17004
-
17005
- // src/commands/ports.ts
17006
- import { spawnSync as spawnSync2 } from "child_process";
17007
-
17008
- // src/commands/ssh.ts
17009
- import { spawnSync } from "child_process";
17010
- function envReachableHosts() {
17011
- const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
17012
- return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
17013
- }
17014
- function isReachable(host) {
17015
- const overrides = envReachableHosts();
17016
- if (overrides.size > 0) {
17017
- return overrides.has(host);
17018
- }
17019
- const probe = spawnSync("bash", ["-lc", `getent hosts ${host} >/dev/null 2>&1 || ping -c 1 -W 1 ${host} >/dev/null 2>&1`], {
17020
- stdio: "ignore"
17021
- });
17022
- return probe.status === 0;
17023
- }
17024
- function resolveSshTarget(machineId) {
17025
- const machine = getManifestMachine(machineId);
17026
- if (!machine) {
17027
- throw new Error(`Machine not found in manifest: ${machineId}`);
17028
- }
17029
- const current = detectCurrentMachineManifest();
17030
- if (machine.id === current.id) {
17031
- return {
17032
- machineId,
17033
- target: "localhost",
17034
- route: "local"
17035
- };
17036
- }
17037
- const lanTarget = machine.sshAddress || machine.hostname || machine.id;
17038
- const tailscaleTarget = machine.tailscaleName || machine.hostname || machine.id;
17039
- const route = isReachable(lanTarget) ? "lan" : "tailscale";
17040
- return {
17041
- machineId,
17042
- target: route === "lan" ? lanTarget : tailscaleTarget,
17043
- route
17318
+ delivered: delivery.delivered,
17319
+ preview,
17320
+ detail: delivery.detail
17044
17321
  };
17045
17322
  }
17046
- function buildSshCommand(machineId, remoteCommand) {
17047
- const resolved = resolveSshTarget(machineId);
17048
- return remoteCommand ? `ssh ${resolved.target} ${JSON.stringify(remoteCommand)}` : `ssh ${resolved.target}`;
17049
- }
17050
17323
 
17051
17324
  // src/commands/ports.ts
17325
+ import { spawnSync as spawnSync3 } from "child_process";
17052
17326
  function parseSsOutput(output) {
17053
17327
  return output.trim().split(`
17054
17328
  `).map((line) => line.trim()).filter(Boolean).map((line) => {
@@ -17090,7 +17364,7 @@ function listPorts(machineId) {
17090
17364
  const isLocal = targetMachineId === getLocalMachineId();
17091
17365
  const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
17092
17366
  const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
17093
- const result = spawnSync2("bash", ["-lc", command], { encoding: "utf8" });
17367
+ const result = spawnSync3("bash", ["-lc", command], { encoding: "utf8" });
17094
17368
  if (result.status !== 0) {
17095
17369
  throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
17096
17370
  }
@@ -17102,7 +17376,7 @@ function listPorts(machineId) {
17102
17376
  }
17103
17377
 
17104
17378
  // src/commands/sync.ts
17105
- import { existsSync as existsSync6, lstatSync, readFileSync as readFileSync6, symlinkSync, copyFileSync as copyFileSync2 } from "fs";
17379
+ import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync6, symlinkSync, copyFileSync as copyFileSync2 } from "fs";
17106
17380
  function quote4(value) {
17107
17381
  return `'${value.replace(/'/g, `'\\''`)}'`;
17108
17382
  }
@@ -17150,8 +17424,8 @@ function detectPackageActions(machine) {
17150
17424
  }
17151
17425
  function detectFileActions(machine) {
17152
17426
  return (machine.files || []).map((file, index) => {
17153
- const sourceExists = existsSync6(file.source);
17154
- const targetExists = existsSync6(file.target);
17427
+ const sourceExists = existsSync7(file.source);
17428
+ const targetExists = existsSync7(file.target);
17155
17429
  let status = "missing";
17156
17430
  if (sourceExists && targetExists) {
17157
17431
  if (file.mode === "symlink") {
@@ -17285,6 +17559,59 @@ function getStatus() {
17285
17559
  };
17286
17560
  }
17287
17561
 
17562
+ // src/commands/doctor.ts
17563
+ function makeCheck(id, status, summary, detail) {
17564
+ return { id, status, summary, detail };
17565
+ }
17566
+ function parseKeyValueOutput(stdout) {
17567
+ return Object.fromEntries(stdout.trim().split(`
17568
+ `).map((line) => line.split("=")).filter((parts) => parts.length === 2).map(([key, value]) => [key, value]));
17569
+ }
17570
+ function buildDoctorCommand() {
17571
+ return [
17572
+ 'data_dir="${HASNA_MACHINES_DIR:-$HOME/.hasna/machines}"',
17573
+ 'manifest_path="${HASNA_MACHINES_MANIFEST_PATH:-$data_dir/machines.json}"',
17574
+ 'db_path="${HASNA_MACHINES_DB_PATH:-$data_dir/machines.db}"',
17575
+ 'notifications_path="${HASNA_MACHINES_NOTIFICATIONS_PATH:-$data_dir/notifications.json}"',
17576
+ `printf 'manifest_path=%s\\n' "$manifest_path"`,
17577
+ `printf 'db_path=%s\\n' "$db_path"`,
17578
+ `printf 'notifications_path=%s\\n' "$notifications_path"`,
17579
+ `printf 'manifest_exists=%s\\n' "$(test -e "$manifest_path" && printf yes || printf no)"`,
17580
+ `printf 'db_exists=%s\\n' "$(test -e "$db_path" && printf yes || printf no)"`,
17581
+ `printf 'notifications_exists=%s\\n' "$(test -e "$notifications_path" && printf yes || printf no)"`,
17582
+ `printf 'bun=%s\\n' "$(bun --version 2>/dev/null || printf missing)"`,
17583
+ `printf 'ssh=%s\\n' "$(command -v ssh >/dev/null 2>&1 && printf ok || printf missing)"`,
17584
+ `printf 'machines=%s\\n' "$(command -v machines 2>/dev/null || printf missing)"`,
17585
+ `printf 'machines_agent=%s\\n' "$(command -v machines-agent 2>/dev/null || printf missing)"`,
17586
+ `printf 'machines_mcp=%s\\n' "$(command -v machines-mcp 2>/dev/null || printf missing)"`
17587
+ ].join("; ");
17588
+ }
17589
+ function runDoctor(machineId = getLocalMachineId()) {
17590
+ const manifest = readManifest();
17591
+ const commandChecks = runMachineCommand(machineId, buildDoctorCommand());
17592
+ const details = parseKeyValueOutput(commandChecks.stdout);
17593
+ const machineInManifest = manifest.machines.find((machine) => machine.id === machineId);
17594
+ const checks = [
17595
+ makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(machineInManifest) : `No manifest entry for ${machineId}`),
17596
+ makeCheck("manifest-path", details["manifest_exists"] === "yes" ? "ok" : "warn", "Manifest path check", `${details["manifest_path"] || "unknown"} ${details["manifest_exists"] === "yes" ? "exists" : "missing"}`),
17597
+ makeCheck("db-path", details["db_exists"] === "yes" ? "ok" : "warn", "DB path check", `${details["db_path"] || "unknown"} ${details["db_exists"] === "yes" ? "exists" : "missing"}`),
17598
+ makeCheck("notifications-path", details["notifications_exists"] === "yes" ? "ok" : "warn", "Notifications path check", `${details["notifications_path"] || "unknown"} ${details["notifications_exists"] === "yes" ? "exists" : "missing"}`),
17599
+ makeCheck("bun", details["bun"] && details["bun"] !== "missing" ? "ok" : "fail", "Bun availability", details["bun"] || "missing"),
17600
+ makeCheck("machines-cli", details["machines"] && details["machines"] !== "missing" ? "ok" : "warn", "machines CLI availability", details["machines"] || "missing"),
17601
+ makeCheck("machines-agent-cli", details["machines_agent"] && details["machines_agent"] !== "missing" ? "ok" : "warn", "machines-agent availability", details["machines_agent"] || "missing"),
17602
+ makeCheck("machines-mcp-cli", details["machines_mcp"] && details["machines_mcp"] !== "missing" ? "ok" : "warn", "machines-mcp availability", details["machines_mcp"] || "missing"),
17603
+ makeCheck("ssh", details["ssh"] === "ok" ? "ok" : "warn", "SSH availability", details["ssh"] || "missing")
17604
+ ];
17605
+ return {
17606
+ machineId,
17607
+ source: commandChecks.source,
17608
+ manifestPath: details["manifest_path"],
17609
+ dbPath: details["db_path"],
17610
+ notificationsPath: details["notifications_path"],
17611
+ checks
17612
+ };
17613
+ }
17614
+
17288
17615
  // src/commands/serve.ts
17289
17616
  function escapeHtml(value) {
17290
17617
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -17296,13 +17623,27 @@ function getServeInfo(options = {}) {
17296
17623
  host,
17297
17624
  port,
17298
17625
  url: `http://${host}:${port}`,
17299
- routes: ["/", "/health", "/api/status", "/api/manifest", "/api/notifications"]
17626
+ routes: [
17627
+ "/",
17628
+ "/health",
17629
+ "/api/status",
17630
+ "/api/manifest",
17631
+ "/api/notifications",
17632
+ "/api/doctor",
17633
+ "/api/self-test",
17634
+ "/api/apps/status",
17635
+ "/api/apps/diff",
17636
+ "/api/install-claude/status",
17637
+ "/api/install-claude/diff",
17638
+ "/api/notifications/test"
17639
+ ]
17300
17640
  };
17301
17641
  }
17302
17642
  function renderDashboardHtml() {
17303
17643
  const status = getStatus();
17304
17644
  const manifest = manifestList();
17305
17645
  const notifications = listNotificationChannels();
17646
+ const doctor = runDoctor();
17306
17647
  return `<!doctype html>
17307
17648
  <html lang="en">
17308
17649
  <head>
@@ -17318,12 +17659,14 @@ function renderDashboardHtml() {
17318
17659
  .card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
17319
17660
  .stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
17320
17661
  table { width: 100%; border-collapse: collapse; }
17321
- th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; }
17662
+ th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
17322
17663
  code { color: #9ed0ff; }
17323
17664
  .badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
17324
- .online { background: #12351f; color: #74f0a7; }
17325
- .offline { background: #3b1a1a; color: #ff8c8c; }
17326
- .unknown { background: #2f2b16; color: #ffd76a; }
17665
+ .online, .ok { background: #12351f; color: #74f0a7; }
17666
+ .offline, .fail { background: #3b1a1a; color: #ff8c8c; }
17667
+ .unknown, .warn { background: #2f2b16; color: #ffd76a; }
17668
+ ul { margin: 8px 0 0; padding-left: 18px; }
17669
+ .muted { color: #9fb0d9; }
17327
17670
  </style>
17328
17671
  </head>
17329
17672
  <body>
@@ -17333,6 +17676,7 @@ function renderDashboardHtml() {
17333
17676
  <section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
17334
17677
  <section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
17335
17678
  <section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
17679
+ <section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
17336
17680
  </div>
17337
17681
 
17338
17682
  <section class="card" style="margin-top:16px">
@@ -17350,6 +17694,25 @@ function renderDashboardHtml() {
17350
17694
  </table>
17351
17695
  </section>
17352
17696
 
17697
+ <section class="card" style="margin-top:16px">
17698
+ <h2>Doctor</h2>
17699
+ <table>
17700
+ <thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
17701
+ <tbody>
17702
+ ${doctor.checks.map((entry) => `<tr>
17703
+ <td>${escapeHtml(entry.summary)}</td>
17704
+ <td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
17705
+ <td class="muted">${escapeHtml(entry.detail)}</td>
17706
+ </tr>`).join("")}
17707
+ </tbody>
17708
+ </table>
17709
+ </section>
17710
+
17711
+ <section class="card" style="margin-top:16px">
17712
+ <h2>Self Test</h2>
17713
+ <p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
17714
+ </section>
17715
+
17353
17716
  <section class="card" style="margin-top:16px">
17354
17717
  <h2>Manifest</h2>
17355
17718
  <pre>${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
@@ -17358,13 +17721,25 @@ function renderDashboardHtml() {
17358
17721
  </body>
17359
17722
  </html>`;
17360
17723
  }
17724
+ async function parseJsonBody(request) {
17725
+ try {
17726
+ return await request.json();
17727
+ } catch {
17728
+ return {};
17729
+ }
17730
+ }
17731
+ function jsonError(message, status = 400) {
17732
+ return Response.json({ error: message }, { status });
17733
+ }
17361
17734
  function startDashboardServer(options = {}) {
17362
17735
  const info = getServeInfo(options);
17363
17736
  return Bun.serve({
17364
17737
  hostname: info.host,
17365
17738
  port: info.port,
17366
- fetch(request) {
17739
+ async fetch(request) {
17367
17740
  const url = new URL(request.url);
17741
+ const machineId = url.searchParams.get("machine") || undefined;
17742
+ const tools = url.searchParams.get("tools")?.split(",").map((value) => value.trim()).filter(Boolean);
17368
17743
  if (url.pathname === "/health") {
17369
17744
  return Response.json({ ok: true, ...getServeInfo(options) });
17370
17745
  }
@@ -17377,6 +17752,43 @@ function startDashboardServer(options = {}) {
17377
17752
  if (url.pathname === "/api/notifications") {
17378
17753
  return Response.json(listNotificationChannels());
17379
17754
  }
17755
+ if (url.pathname === "/api/doctor") {
17756
+ return Response.json(runDoctor(machineId));
17757
+ }
17758
+ if (url.pathname === "/api/self-test") {
17759
+ return Response.json(runSelfTest());
17760
+ }
17761
+ if (url.pathname === "/api/apps/status") {
17762
+ return Response.json(getAppsStatus(machineId));
17763
+ }
17764
+ if (url.pathname === "/api/apps/diff") {
17765
+ return Response.json(diffApps(machineId));
17766
+ }
17767
+ if (url.pathname === "/api/install-claude/status") {
17768
+ return Response.json(getClaudeCliStatus(machineId, tools));
17769
+ }
17770
+ if (url.pathname === "/api/install-claude/diff") {
17771
+ return Response.json(diffClaudeCli(machineId, tools));
17772
+ }
17773
+ if (url.pathname === "/api/notifications/test") {
17774
+ if (request.method !== "POST") {
17775
+ return jsonError("Use POST for notification tests.", 405);
17776
+ }
17777
+ const body = await parseJsonBody(request);
17778
+ const channelId = typeof body["channelId"] === "string" ? body["channelId"] : undefined;
17779
+ if (!channelId) {
17780
+ return jsonError("channelId is required.");
17781
+ }
17782
+ const event = typeof body["event"] === "string" ? body["event"] : undefined;
17783
+ const message = typeof body["message"] === "string" ? body["message"] : undefined;
17784
+ const apply = body["apply"] === true;
17785
+ const yes = body["yes"] === true;
17786
+ try {
17787
+ return Response.json(await testNotificationChannel(channelId, event, message, { apply, yes }));
17788
+ } catch (error) {
17789
+ return jsonError(error instanceof Error ? error.message : String(error));
17790
+ }
17791
+ }
17380
17792
  return new Response(renderDashboardHtml(), {
17381
17793
  headers: {
17382
17794
  "content-type": "text/html; charset=utf-8"
@@ -17386,11 +17798,178 @@ function startDashboardServer(options = {}) {
17386
17798
  });
17387
17799
  }
17388
17800
 
17801
+ // src/commands/self-test.ts
17802
+ function check(id, status, summary, detail) {
17803
+ return { id, status, summary, detail };
17804
+ }
17805
+ function runSelfTest() {
17806
+ const version = getPackageVersion();
17807
+ const status = getStatus();
17808
+ const doctor = runDoctor();
17809
+ const serveInfo = getServeInfo();
17810
+ const html = renderDashboardHtml();
17811
+ const notifications = listNotificationChannels();
17812
+ const apps = listApps(status.machineId);
17813
+ const appsDiff = diffApps(status.machineId);
17814
+ const cliPlan = buildClaudeInstallPlan(status.machineId);
17815
+ return {
17816
+ machineId: getLocalMachineId(),
17817
+ checks: [
17818
+ check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
17819
+ check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
17820
+ check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
17821
+ check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
17822
+ check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
17823
+ check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
17824
+ check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
17825
+ check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
17826
+ check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
17827
+ ]
17828
+ };
17829
+ }
17830
+
17831
+ // src/cli-utils.ts
17832
+ function parseIntegerOption(value, label, constraints = {}) {
17833
+ const parsed = Number.parseInt(value, 10);
17834
+ const { min, max } = constraints;
17835
+ if (!Number.isFinite(parsed) || !/^-?\d+$/.test(value.trim())) {
17836
+ throw new Error(`Invalid value for ${label}: ${value}`);
17837
+ }
17838
+ if (min !== undefined && parsed < min) {
17839
+ throw new Error(`Invalid value for ${label}: ${value}. Expected >= ${min}.`);
17840
+ }
17841
+ if (max !== undefined && parsed > max) {
17842
+ throw new Error(`Invalid value for ${label}: ${value}. Expected <= ${max}.`);
17843
+ }
17844
+ return parsed;
17845
+ }
17846
+ function renderKeyValueTable(entries) {
17847
+ const width = entries.reduce((max, [key]) => Math.max(max, key.length), 0);
17848
+ return entries.map(([key, value]) => `${key.padEnd(width)} ${value}`).join(`
17849
+ `);
17850
+ }
17851
+ function renderList(title, items) {
17852
+ if (items.length === 0) {
17853
+ return `${title}: none`;
17854
+ }
17855
+ return `${title}:
17856
+ ${items.map((item) => `- ${item}`).join(`
17857
+ `)}`;
17858
+ }
17859
+
17389
17860
  // src/cli/index.ts
17390
17861
  var program2 = new Command;
17862
+ function printJsonOrText(data, text, json = false) {
17863
+ if (json) {
17864
+ console.log(JSON.stringify(data, null, 2));
17865
+ return;
17866
+ }
17867
+ console.log(text);
17868
+ }
17869
+ function renderAppsListResult(result) {
17870
+ return [
17871
+ `machine: ${result.machineId}`,
17872
+ renderList("apps", result.apps.map((app) => `${app.name}${app.manager ? ` (${app.manager})` : ""}`))
17873
+ ].join(`
17874
+ `);
17875
+ }
17876
+ function renderAppsStatusResult(result) {
17877
+ const lines = result.apps.map((app) => {
17878
+ const state = app.installed ? source_default.green("installed") : source_default.yellow("missing");
17879
+ return `${app.name.padEnd(18)} ${state} ${app.version ? `v${app.version}` : ""}`.trimEnd();
17880
+ });
17881
+ return [`machine: ${result.machineId} (${result.source})`, ...lines].join(`
17882
+ `);
17883
+ }
17884
+ function renderAppsDiffResult(result) {
17885
+ return [
17886
+ `machine: ${result.machineId} (${result.source})`,
17887
+ renderList("missing", result.missing),
17888
+ renderList("installed", result.installed)
17889
+ ].join(`
17890
+ `);
17891
+ }
17892
+ function renderClaudeStatusResult(result) {
17893
+ const lines = result.tools.map((tool) => {
17894
+ const state = tool.installed ? source_default.green("installed") : source_default.yellow("missing");
17895
+ return `${tool.tool.padEnd(8)} ${state} ${tool.version || ""}`.trimEnd();
17896
+ });
17897
+ return [`machine: ${result.machineId} (${result.source})`, ...lines].join(`
17898
+ `);
17899
+ }
17900
+ function renderClaudeDiffResult(result) {
17901
+ return [
17902
+ `machine: ${result.machineId} (${result.source})`,
17903
+ renderList("missing", result.missing),
17904
+ renderList("installed", result.installed)
17905
+ ].join(`
17906
+ `);
17907
+ }
17908
+ function renderNotificationConfigResult(config) {
17909
+ if (config.channels.length === 0) {
17910
+ return "notification channels: none";
17911
+ }
17912
+ return config.channels.map((channel) => `${channel.id} ${channel.enabled ? source_default.green("enabled") : source_default.yellow("disabled")} ${channel.type} -> ${channel.target}`).join(`
17913
+ `);
17914
+ }
17915
+ function renderNotificationTestResult(result) {
17916
+ return renderKeyValueTable([
17917
+ ["channel", result.channelId],
17918
+ ["mode", result.mode],
17919
+ ["delivered", String(result.delivered)],
17920
+ ["detail", result.detail],
17921
+ ["preview", result.preview]
17922
+ ]);
17923
+ }
17924
+ function renderNotificationDispatchResult(result) {
17925
+ return [
17926
+ `event: ${result.event}`,
17927
+ `message: ${result.message}`,
17928
+ ...result.deliveries.map((delivery) => `${delivery.channelId} ${delivery.delivered ? source_default.green("delivered") : source_default.red("failed")} ${delivery.transport} ${delivery.detail}`)
17929
+ ].join(`
17930
+ `);
17931
+ }
17932
+ function renderDoctorResult(report) {
17933
+ const header = `machine: ${report.machineId} (${report.source})`;
17934
+ const lines = report.checks.map((check2) => {
17935
+ const status = check2.status === "ok" ? source_default.green(check2.status) : check2.status === "warn" ? source_default.yellow(check2.status) : source_default.red(check2.status);
17936
+ return `${check2.id.padEnd(20)} ${status} ${check2.detail}`;
17937
+ });
17938
+ return [header, ...lines].join(`
17939
+ `);
17940
+ }
17941
+ function renderSelfTestResult(result) {
17942
+ return [
17943
+ `machine: ${result.machineId}`,
17944
+ ...result.checks.map((check2) => {
17945
+ const status = check2.status === "ok" ? source_default.green(check2.status) : check2.status === "warn" ? source_default.yellow(check2.status) : source_default.red(check2.status);
17946
+ return `${check2.id.padEnd(20)} ${status} ${check2.detail}`;
17947
+ })
17948
+ ].join(`
17949
+ `);
17950
+ }
17951
+ function renderFleetStatus(status) {
17952
+ return [
17953
+ renderKeyValueTable([
17954
+ ["machine", status.machineId],
17955
+ ["manifest", status.manifestPath],
17956
+ ["db", status.dbPath],
17957
+ ["notifications", status.notificationsPath],
17958
+ ["manifest machines", String(status.manifestMachineCount)],
17959
+ ["heartbeats", String(status.heartbeatCount)],
17960
+ ["setup runs", String(status.recentSetupRuns)],
17961
+ ["sync runs", String(status.recentSyncRuns)]
17962
+ ]),
17963
+ "",
17964
+ ...status.machines.map((machine) => `${machine.machineId.padEnd(18)} ${machine.platform || "unknown"} ${machine.heartbeatStatus} ${machine.lastHeartbeatAt || "\u2014"}`)
17965
+ ].join(`
17966
+ `);
17967
+ }
17391
17968
  program2.name("machines").description("Machine fleet management CLI + MCP for developers").version(getPackageVersion());
17392
17969
  var manifestCommand = program2.command("manifest").description("Manage the fleet manifest");
17393
17970
  var appsCommand = program2.command("apps").description("Manage installed applications per machine");
17971
+ var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
17972
+ var installClaudeCommand = program2.command("install-claude").description("Install or inspect Claude, Codex, and Gemini CLIs");
17394
17973
  manifestCommand.command("init").description("Create an empty fleet manifest").action(() => {
17395
17974
  console.log(manifestInit());
17396
17975
  });
@@ -17423,11 +18002,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
17423
18002
  const files = Array.isArray(options["file"]) ? options["file"].map((value) => {
17424
18003
  const [source, target, mode] = String(value).split(":");
17425
18004
  const normalizedMode = mode === "symlink" ? "symlink" : mode === "copy" ? "copy" : undefined;
17426
- return {
17427
- source,
17428
- target,
17429
- mode: normalizedMode
17430
- };
18005
+ return { source, target, mode: normalizedMode };
17431
18006
  }) : undefined;
17432
18007
  const apps = Array.isArray(options["app"]) ? options["app"].map((value) => {
17433
18008
  const [name, manager, packageName] = String(value).split(":");
@@ -17455,65 +18030,55 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
17455
18030
  });
17456
18031
  appsCommand.command("list").description("List manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
17457
18032
  const result = listApps(options.machine);
17458
- const rendered = JSON.stringify(result, null, 2);
17459
- console.log(options.json ? rendered : rendered);
18033
+ printJsonOrText(result, renderAppsListResult(result), options.json);
18034
+ });
18035
+ appsCommand.command("status").description("Check installed state for manifest-managed apps").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
18036
+ const result = getAppsStatus(options.machine);
18037
+ printJsonOrText(result, renderAppsStatusResult(result), options.json);
18038
+ });
18039
+ appsCommand.command("diff").description("Show missing and installed manifest-managed apps").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
18040
+ const result = diffApps(options.machine);
18041
+ printJsonOrText(result, renderAppsDiffResult(result), options.json);
17460
18042
  });
17461
18043
  appsCommand.command("plan").description("Preview app install steps for a machine").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
17462
18044
  const result = buildAppsPlan(options.machine);
17463
- const rendered = JSON.stringify(result, null, 2);
17464
- console.log(options.json ? rendered : rendered);
18045
+ console.log(options.json ? JSON.stringify(result, null, 2) : JSON.stringify(result, null, 2));
17465
18046
  });
17466
18047
  appsCommand.command("apply").description("Install manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("--yes", "Confirm execution", false).option("-j, --json", "Print JSON output", false).action((options) => {
17467
18048
  const result = runAppsInstall(options.machine, { apply: true, yes: options.yes });
17468
- const rendered = JSON.stringify(result, null, 2);
17469
- console.log(options.json ? rendered : rendered);
18049
+ console.log(options.json ? JSON.stringify(result, null, 2) : JSON.stringify(result, null, 2));
17470
18050
  });
17471
18051
  program2.command("setup").description("Prepare a machine from the fleet manifest").option("--machine <id>", "Machine identifier").option("--apply", "Execute provisioning commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
17472
18052
  const result = options.apply ? runSetup(options.machine, { apply: true, yes: options.yes }) : buildSetupPlan(options.machine);
17473
- if (options.json) {
17474
- console.log(JSON.stringify(result, null, 2));
17475
- return;
17476
- }
17477
18053
  console.log(JSON.stringify(result, null, 2));
17478
18054
  });
17479
18055
  program2.command("sync").description("Reconcile a machine against the fleet manifest").option("--machine <id>", "Machine identifier").option("--apply", "Execute reconciliation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
17480
18056
  const result = options.apply ? runSync(options.machine, { apply: true, yes: options.yes }) : buildSyncPlan(options.machine);
17481
- if (options.json) {
17482
- console.log(JSON.stringify(result, null, 2));
17483
- return;
17484
- }
17485
18057
  console.log(JSON.stringify(result, null, 2));
17486
18058
  });
17487
18059
  program2.command("diff").description("Show manifest differences between two machines").requiredOption("--left <id>", "Left machine identifier").option("--right <id>", "Right machine identifier (defaults to current machine)").option("-j, --json", "Print JSON output", false).action((options) => {
17488
18060
  const result = diffMachines(options.left, options.right);
17489
- if (options.json) {
17490
- console.log(JSON.stringify(result, null, 2));
17491
- return;
17492
- }
17493
18061
  console.log(JSON.stringify(result, null, 2));
17494
18062
  });
17495
18063
  program2.command("backup").description("Create and optionally upload a machine backup archive").requiredOption("--bucket <name>", "S3 bucket name").option("--prefix <prefix>", "S3 key prefix", "machines").option("--apply", "Execute backup commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
17496
18064
  const result = options.apply ? runBackup(options.bucket, options.prefix, { apply: true, yes: options.yes }) : buildBackupPlan(options.bucket, options.prefix);
17497
- const rendered = JSON.stringify(result, null, 2);
17498
- console.log(options.json ? rendered : rendered);
18065
+ console.log(JSON.stringify(result, null, 2));
17499
18066
  });
17500
18067
  var certCommand = program2.command("cert").description("Manage mkcert-based local SSL certificates");
17501
18068
  certCommand.command("issue").description("Plan or issue certificates for one or more domains").argument("<domains...>", "Domains to include in the certificate").option("--apply", "Execute certificate commands instead of previewing them", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((domains, options) => {
17502
18069
  const result = options.apply ? runCertPlan(domains, { apply: true, yes: options.yes }) : buildCertPlan(domains);
17503
- const rendered = JSON.stringify(result, null, 2);
17504
- console.log(options.json ? rendered : rendered);
18070
+ console.log(JSON.stringify(result, null, 2));
17505
18071
  });
17506
18072
  var dnsCommand = program2.command("dns").description("Manage local domain mappings");
17507
- var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
17508
18073
  dnsCommand.command("add").description("Add or replace a local domain mapping").requiredOption("--domain <domain>", "Domain name").requiredOption("--port <port>", "Target port").option("--target-host <host>", "Target host", "127.0.0.1").option("-j, --json", "Print JSON output", false).action((options) => {
17509
- const result = addDomainMapping(options.domain, Number.parseInt(options.port, 10), options.targetHost);
17510
- const rendered = JSON.stringify(result, null, 2);
17511
- console.log(options.json ? rendered : rendered);
18074
+ const result = addDomainMapping(options.domain, parseIntegerOption(options.port, "port", { min: 1, max: 65535 }), options.targetHost);
18075
+ console.log(JSON.stringify(result, null, 2));
18076
+ });
18077
+ dnsCommand.command("list").description("List saved local domain mappings").option("-j, --json", "Print JSON output", false).action(() => {
18078
+ console.log(JSON.stringify(listDomainMappings(), null, 2));
17512
18079
  });
17513
- dnsCommand.command("list").description("List saved local domain mappings").option("-j, --json", "Print JSON output", false).action((options) => {
17514
- const result = listDomainMappings();
17515
- const rendered = JSON.stringify(result, null, 2);
17516
- console.log(options.json ? rendered : rendered);
18080
+ dnsCommand.command("render").description("Render hosts/proxy configuration for a domain").argument("<domain>", "Domain name").option("-j, --json", "Print JSON output", false).action((domain) => {
18081
+ console.log(JSON.stringify(renderDomainMapping(domain), null, 2));
17517
18082
  });
17518
18083
  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 shell command").option("--event <event...>", "Events routed to this channel", ["setup_failed", "sync_failed"]).option("--disabled", "Create the channel in disabled state", false).option("-j, --json", "Print JSON output", false).action((options) => {
17519
18084
  const result = addNotificationChannel({
@@ -17523,41 +18088,50 @@ notificationsCommand.command("add").description("Add or replace a notification c
17523
18088
  events: options.event,
17524
18089
  enabled: !options.disabled
17525
18090
  });
17526
- const rendered = JSON.stringify(result, null, 2);
17527
- console.log(options.json ? rendered : rendered);
18091
+ printJsonOrText(result, renderNotificationConfigResult(result), options.json);
17528
18092
  });
17529
18093
  notificationsCommand.command("list").description("List configured notification channels").option("-j, --json", "Print JSON output", false).action((options) => {
17530
18094
  const result = listNotificationChannels();
17531
- const rendered = JSON.stringify(result, null, 2);
17532
- console.log(options.json ? rendered : rendered);
18095
+ printJsonOrText(result, renderNotificationConfigResult(result), options.json);
17533
18096
  });
17534
- notificationsCommand.command("test").description("Preview or execute a notification test").requiredOption("--channel <id>", "Channel identifier").option("--event <name>", "Event name", "manual.test").option("--message <message>", "Test message", "machines notification test").option("--apply", "Execute the notification test instead of previewing it", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
17535
- const result = testNotificationChannel(options.channel, options.event, options.message, {
18097
+ notificationsCommand.command("test").description("Preview or execute a notification test").requiredOption("--channel <id>", "Channel identifier").option("--event <name>", "Event name", "manual.test").option("--message <message>", "Test message", "machines notification test").option("--apply", "Execute the notification test instead of previewing it", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action(async (options) => {
18098
+ const result = await testNotificationChannel(options.channel, options.event, options.message, {
17536
18099
  apply: options.apply,
17537
18100
  yes: options.yes
17538
18101
  });
17539
- const rendered = JSON.stringify(result, null, 2);
17540
- console.log(options.json ? rendered : rendered);
18102
+ printJsonOrText(result, renderNotificationTestResult(result), options.json);
18103
+ });
18104
+ notificationsCommand.command("dispatch").description("Dispatch an event to matching notification channels").requiredOption("--event <name>", "Event name").requiredOption("--message <message>", "Message body").option("--channel <id>", "Limit delivery to one channel").option("-j, --json", "Print JSON output", false).action(async (options) => {
18105
+ const result = await dispatchNotificationEvent(options.event, options.message, { channelId: options.channel });
18106
+ printJsonOrText(result, renderNotificationDispatchResult(result), options.json);
17541
18107
  });
17542
18108
  notificationsCommand.command("remove").description("Remove a notification channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action((id, options) => {
17543
18109
  const result = removeNotificationChannel(id);
17544
- const rendered = JSON.stringify(result, null, 2);
17545
- console.log(options.json ? rendered : rendered);
18110
+ printJsonOrText(result, renderNotificationConfigResult(result), options.json);
18111
+ });
18112
+ 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) => {
18113
+ const result = getClaudeCliStatus(options.machine, options.tool);
18114
+ printJsonOrText(result, renderClaudeStatusResult(result), options.json);
18115
+ });
18116
+ installClaudeCommand.command("diff").description("Show missing and installed 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) => {
18117
+ const result = diffClaudeCli(options.machine, options.tool);
18118
+ printJsonOrText(result, renderClaudeDiffResult(result), options.json);
17546
18119
  });
17547
- dnsCommand.command("render").description("Render hosts/proxy configuration for a domain").argument("<domain>", "Domain name").option("-j, --json", "Print JSON output", false).action((domain, options) => {
17548
- const result = renderDomainMapping(domain);
17549
- const rendered = JSON.stringify(result, null, 2);
17550
- console.log(options.json ? rendered : rendered);
18120
+ installClaudeCommand.command("plan").description("Preview CLI install steps").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to install (claude, codex, gemini)").option("-j, --json", "Print JSON output", false).action((options) => {
18121
+ const result = buildClaudeInstallPlan(options.machine, options.tool);
18122
+ console.log(JSON.stringify(result, null, 2));
18123
+ });
18124
+ installClaudeCommand.command("apply").description("Install or update the requested CLIs").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to install (claude, codex, gemini)").option("--yes", "Confirm execution when using apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
18125
+ const result = runClaudeInstall(options.machine, options.tool, { apply: true, yes: options.yes });
18126
+ console.log(JSON.stringify(result, null, 2));
17551
18127
  });
17552
- program2.command("install-claude").description("Install or update Claude, Codex, and Gemini CLIs across the fleet").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to install (claude, codex, gemini)").option("--apply", "Execute installation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
18128
+ installClaudeCommand.option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to install (claude, codex, gemini)").option("--apply", "Execute installation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
17553
18129
  const result = options.apply ? runClaudeInstall(options.machine, options.tool, { apply: true, yes: options.yes }) : buildClaudeInstallPlan(options.machine, options.tool);
17554
- const rendered = JSON.stringify(result, null, 2);
17555
- console.log(options.json ? rendered : rendered);
18130
+ console.log(JSON.stringify(result, null, 2));
17556
18131
  });
17557
18132
  program2.command("install-tailscale").description("Install Tailscale on a machine").option("--machine <id>", "Machine identifier").option("--apply", "Execute installation commands instead of previewing the plan", false).option("--yes", "Confirm execution when using --apply", false).option("-j, --json", "Print JSON output", false).action((options) => {
17558
18133
  const result = options.apply ? runTailscaleInstall(options.machine, { apply: true, yes: options.yes }) : buildTailscaleInstallPlan(options.machine);
17559
- const rendered = JSON.stringify(result, null, 2);
17560
- console.log(options.json ? rendered : rendered);
18134
+ console.log(JSON.stringify(result, null, 2));
17561
18135
  });
17562
18136
  program2.command("ssh").description("Choose the best SSH route for a machine").requiredOption("--machine <id>", "Machine identifier").option("--cmd <command>", "Remote command to run").option("-j, --json", "Print JSON output", false).action((options) => {
17563
18137
  if (options.json) {
@@ -17568,27 +18142,27 @@ program2.command("ssh").description("Choose the best SSH route for a machine").r
17568
18142
  });
17569
18143
  program2.command("ports").description("List listening ports on a machine").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
17570
18144
  const result = listPorts(options.machine);
17571
- const rendered = JSON.stringify(result, null, 2);
17572
- console.log(options.json ? rendered : rendered);
18145
+ console.log(JSON.stringify(result, null, 2));
17573
18146
  });
17574
18147
  program2.command("status").description("Print local machine and storage status").option("-j, --json", "Print JSON output", false).action((options) => {
17575
18148
  const status = getStatus();
17576
- const rendered = JSON.stringify(status, null, 2);
17577
- console.log(options.json ? rendered : source_default.cyan(rendered));
18149
+ printJsonOrText(status, renderFleetStatus(status), options.json);
18150
+ });
18151
+ program2.command("doctor").description("Run machine preflight checks").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
18152
+ const result = runDoctor(options.machine);
18153
+ printJsonOrText(result, renderDoctorResult(result), options.json);
18154
+ });
18155
+ program2.command("self-test").description("Run local package smoke checks").option("-j, --json", "Print JSON output", false).action((options) => {
18156
+ const result = runSelfTest();
18157
+ printJsonOrText(result, renderSelfTestResult(result), options.json);
17578
18158
  });
17579
18159
  program2.command("serve").description("Serve a local fleet dashboard and JSON API").option("--host <host>", "Host interface to bind", "0.0.0.0").option("--port <port>", "Port to bind", "7676").option("-j, --json", "Print serve config and exit", false).action((options) => {
17580
- const info = getServeInfo({
17581
- host: options.host,
17582
- port: Number.parseInt(options.port, 10)
17583
- });
18160
+ const info = getServeInfo({ host: options.host, port: parseIntegerOption(options.port, "port", { min: 1, max: 65535 }) });
17584
18161
  if (options.json) {
17585
18162
  console.log(JSON.stringify(info, null, 2));
17586
18163
  return;
17587
18164
  }
17588
- const server = startDashboardServer({
17589
- host: info.host,
17590
- port: info.port
17591
- });
18165
+ const server = startDashboardServer({ host: info.host, port: info.port });
17592
18166
  console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
17593
18167
  });
17594
18168
  await program2.parseAsync(process.argv);