@hasna/machines 0.0.2 → 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
@@ -16698,17 +16698,95 @@ function diffMachines(leftMachineId, rightMachineId) {
16698
16698
  };
16699
16699
  }
16700
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
+
16701
16766
  // src/commands/apps.ts
16702
16767
  function getPackageName(app) {
16703
16768
  return app.packageName || app.name;
16704
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
+ }
16705
16782
  function buildAppCommand(machine, app) {
16706
16783
  const packageName = getPackageName(app);
16707
- if (app.manager === "custom") {
16784
+ const manager = getAppManager(machine, app);
16785
+ if (manager === "custom") {
16708
16786
  return packageName;
16709
16787
  }
16710
16788
  if (machine.platform === "macos") {
16711
- if (app.manager === "cask") {
16789
+ if (manager === "cask") {
16712
16790
  return `brew install --cask ${packageName}`;
16713
16791
  }
16714
16792
  return `brew install ${packageName}`;
@@ -16718,18 +16796,48 @@ function buildAppCommand(machine, app) {
16718
16796
  }
16719
16797
  return `sudo apt-get install -y ${packageName}`;
16720
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
+ }
16721
16816
  function buildAppSteps(machine) {
16722
16817
  return (machine.apps || []).map((app) => ({
16723
16818
  id: `app-${app.name}`,
16724
16819
  title: `Install ${app.name} on ${machine.id}`,
16725
16820
  command: buildAppCommand(machine, app),
16726
- 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",
16727
16822
  privileged: machine.platform === "linux"
16728
16823
  }));
16729
16824
  }
16730
16825
  function resolveMachine(machineId) {
16731
16826
  return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
16732
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
+ }
16733
16841
  function listApps(machineId) {
16734
16842
  const machine = resolveMachine(machineId);
16735
16843
  return {
@@ -16746,6 +16854,26 @@ function buildAppsPlan(machineId) {
16746
16854
  executed: 0
16747
16855
  };
16748
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
+ }
16749
16877
  function runAppsInstall(machineId, options = {}) {
16750
16878
  const plan = buildAppsPlan(machineId);
16751
16879
  if (!options.apply)
@@ -16779,6 +16907,13 @@ var AI_CLI_PACKAGES = {
16779
16907
  codex: "@openai/codex",
16780
16908
  gemini: "@google/gemini-cli"
16781
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
+ }
16782
16917
  function normalizeTools(tools) {
16783
16918
  if (!tools || tools.length === 0) {
16784
16919
  return ["claude", "codex", "gemini"];
@@ -16798,8 +16933,27 @@ function buildInstallSteps(machine, tools) {
16798
16933
  manager: "bun"
16799
16934
  }));
16800
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
+ }
16801
16955
  function buildClaudeInstallPlan(machineId, tools) {
16802
- const machine = (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
16956
+ const machine = resolveMachine2(machineId);
16803
16957
  return {
16804
16958
  machineId: machine.id,
16805
16959
  mode: "plan",
@@ -16807,6 +16961,24 @@ function buildClaudeInstallPlan(machineId, tools) {
16807
16961
  executed: 0
16808
16962
  };
16809
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
+ }
16810
16982
  function runClaudeInstall(machineId, tools, options = {}) {
16811
16983
  const plan = buildClaudeInstallPlan(machineId, tools);
16812
16984
  if (!options.apply)
@@ -16919,6 +17091,138 @@ var notificationConfigSchema = exports_external.object({
16919
17091
  function sortChannels(channels) {
16920
17092
  return [...channels].sort((left, right) => left.id.localeCompare(right.id));
16921
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
+ }
16922
17226
  function getDefaultNotificationConfig() {
16923
17227
  return {
16924
17228
  version: 1,
@@ -16962,16 +17266,34 @@ function removeNotificationChannel(channelId) {
16962
17266
  channels: config.channels.filter((channel) => channel.id !== channelId)
16963
17267
  });
16964
17268
  }
16965
- function buildNotificationPreview(channel, event, message) {
16966
- if (channel.type === "email") {
16967
- return `send email to ${channel.target}: [${event}] ${message}`;
16968
- }
16969
- if (channel.type === "webhook") {
16970
- 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
+ }
16971
17289
  }
16972
- return `${channel.target} --event ${event} --message ${JSON.stringify(message)}`;
17290
+ return {
17291
+ event,
17292
+ message,
17293
+ deliveries
17294
+ };
16973
17295
  }
16974
- function testNotificationChannel(channelId, event = "manual.test", message = "machines notification test", options = {}) {
17296
+ async function testNotificationChannel(channelId, event = "manual.test", message = "machines notification test", options = {}) {
16975
17297
  const channel = readNotificationConfig().channels.find((entry) => entry.id === channelId);
16976
17298
  if (!channel) {
16977
17299
  throw new Error(`Notification channel not found: ${channelId}`);
@@ -16982,77 +17304,25 @@ function testNotificationChannel(channelId, event = "manual.test", message = "ma
16982
17304
  channelId,
16983
17305
  mode: "plan",
16984
17306
  delivered: false,
16985
- preview
17307
+ preview,
17308
+ detail: "Preview only"
16986
17309
  };
16987
17310
  }
16988
17311
  if (!options.yes) {
16989
17312
  throw new Error("Notification test execution requires --yes.");
16990
17313
  }
16991
- if (channel.type === "command") {
16992
- const result = Bun.spawnSync(["bash", "-lc", preview], {
16993
- stdout: "pipe",
16994
- stderr: "pipe",
16995
- env: process.env
16996
- });
16997
- if (result.exitCode !== 0) {
16998
- throw new Error(`Notification command failed (${channel.id}): ${result.stderr.toString().trim()}`);
16999
- }
17000
- }
17314
+ const delivery = await dispatchChannel(channel, event, message);
17001
17315
  return {
17002
17316
  channelId,
17003
17317
  mode: "apply",
17004
- delivered: channel.enabled,
17005
- preview
17318
+ delivered: delivery.delivered,
17319
+ preview,
17320
+ detail: delivery.detail
17006
17321
  };
17007
17322
  }
17008
17323
 
17009
17324
  // src/commands/ports.ts
17010
- import { spawnSync as spawnSync2 } from "child_process";
17011
-
17012
- // src/commands/ssh.ts
17013
- import { spawnSync } from "child_process";
17014
- function envReachableHosts() {
17015
- const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
17016
- return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
17017
- }
17018
- function isReachable(host) {
17019
- const overrides = envReachableHosts();
17020
- if (overrides.size > 0) {
17021
- return overrides.has(host);
17022
- }
17023
- const probe = spawnSync("bash", ["-lc", `getent hosts ${host} >/dev/null 2>&1 || ping -c 1 -W 1 ${host} >/dev/null 2>&1`], {
17024
- stdio: "ignore"
17025
- });
17026
- return probe.status === 0;
17027
- }
17028
- function resolveSshTarget(machineId) {
17029
- const machine = getManifestMachine(machineId);
17030
- if (!machine) {
17031
- throw new Error(`Machine not found in manifest: ${machineId}`);
17032
- }
17033
- const current = detectCurrentMachineManifest();
17034
- if (machine.id === current.id) {
17035
- return {
17036
- machineId,
17037
- target: "localhost",
17038
- route: "local"
17039
- };
17040
- }
17041
- const lanTarget = machine.sshAddress || machine.hostname || machine.id;
17042
- const tailscaleTarget = machine.tailscaleName || machine.hostname || machine.id;
17043
- const route = isReachable(lanTarget) ? "lan" : "tailscale";
17044
- return {
17045
- machineId,
17046
- target: route === "lan" ? lanTarget : tailscaleTarget,
17047
- route
17048
- };
17049
- }
17050
- function buildSshCommand(machineId, remoteCommand) {
17051
- const resolved = resolveSshTarget(machineId);
17052
- return remoteCommand ? `ssh ${resolved.target} ${JSON.stringify(remoteCommand)}` : `ssh ${resolved.target}`;
17053
- }
17054
-
17055
- // src/commands/ports.ts
17325
+ import { spawnSync as spawnSync3 } from "child_process";
17056
17326
  function parseSsOutput(output) {
17057
17327
  return output.trim().split(`
17058
17328
  `).map((line) => line.trim()).filter(Boolean).map((line) => {
@@ -17094,7 +17364,7 @@ function listPorts(machineId) {
17094
17364
  const isLocal = targetMachineId === getLocalMachineId();
17095
17365
  const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
17096
17366
  const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
17097
- const result = spawnSync2("bash", ["-lc", command], { encoding: "utf8" });
17367
+ const result = spawnSync3("bash", ["-lc", command], { encoding: "utf8" });
17098
17368
  if (result.status !== 0) {
17099
17369
  throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
17100
17370
  }
@@ -17289,6 +17559,59 @@ function getStatus() {
17289
17559
  };
17290
17560
  }
17291
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
+
17292
17615
  // src/commands/serve.ts
17293
17616
  function escapeHtml(value) {
17294
17617
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
@@ -17300,13 +17623,27 @@ function getServeInfo(options = {}) {
17300
17623
  host,
17301
17624
  port,
17302
17625
  url: `http://${host}:${port}`,
17303
- 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
+ ]
17304
17640
  };
17305
17641
  }
17306
17642
  function renderDashboardHtml() {
17307
17643
  const status = getStatus();
17308
17644
  const manifest = manifestList();
17309
17645
  const notifications = listNotificationChannels();
17646
+ const doctor = runDoctor();
17310
17647
  return `<!doctype html>
17311
17648
  <html lang="en">
17312
17649
  <head>
@@ -17322,12 +17659,14 @@ function renderDashboardHtml() {
17322
17659
  .card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
17323
17660
  .stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
17324
17661
  table { width: 100%; border-collapse: collapse; }
17325
- 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; }
17326
17663
  code { color: #9ed0ff; }
17327
17664
  .badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
17328
- .online { background: #12351f; color: #74f0a7; }
17329
- .offline { background: #3b1a1a; color: #ff8c8c; }
17330
- .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; }
17331
17670
  </style>
17332
17671
  </head>
17333
17672
  <body>
@@ -17337,6 +17676,7 @@ function renderDashboardHtml() {
17337
17676
  <section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
17338
17677
  <section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
17339
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>
17340
17680
  </div>
17341
17681
 
17342
17682
  <section class="card" style="margin-top:16px">
@@ -17354,6 +17694,25 @@ function renderDashboardHtml() {
17354
17694
  </table>
17355
17695
  </section>
17356
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
+
17357
17716
  <section class="card" style="margin-top:16px">
17358
17717
  <h2>Manifest</h2>
17359
17718
  <pre>${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
@@ -17362,13 +17721,25 @@ function renderDashboardHtml() {
17362
17721
  </body>
17363
17722
  </html>`;
17364
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
+ }
17365
17734
  function startDashboardServer(options = {}) {
17366
17735
  const info = getServeInfo(options);
17367
17736
  return Bun.serve({
17368
17737
  hostname: info.host,
17369
17738
  port: info.port,
17370
- fetch(request) {
17739
+ async fetch(request) {
17371
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);
17372
17743
  if (url.pathname === "/health") {
17373
17744
  return Response.json({ ok: true, ...getServeInfo(options) });
17374
17745
  }
@@ -17381,6 +17752,43 @@ function startDashboardServer(options = {}) {
17381
17752
  if (url.pathname === "/api/notifications") {
17382
17753
  return Response.json(listNotificationChannels());
17383
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
+ }
17384
17792
  return new Response(renderDashboardHtml(), {
17385
17793
  headers: {
17386
17794
  "content-type": "text/html; charset=utf-8"
@@ -17390,11 +17798,178 @@ function startDashboardServer(options = {}) {
17390
17798
  });
17391
17799
  }
17392
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
+
17393
17860
  // src/cli/index.ts
17394
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
+ }
17395
17968
  program2.name("machines").description("Machine fleet management CLI + MCP for developers").version(getPackageVersion());
17396
17969
  var manifestCommand = program2.command("manifest").description("Manage the fleet manifest");
17397
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");
17398
17973
  manifestCommand.command("init").description("Create an empty fleet manifest").action(() => {
17399
17974
  console.log(manifestInit());
17400
17975
  });
@@ -17427,11 +18002,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
17427
18002
  const files = Array.isArray(options["file"]) ? options["file"].map((value) => {
17428
18003
  const [source, target, mode] = String(value).split(":");
17429
18004
  const normalizedMode = mode === "symlink" ? "symlink" : mode === "copy" ? "copy" : undefined;
17430
- return {
17431
- source,
17432
- target,
17433
- mode: normalizedMode
17434
- };
18005
+ return { source, target, mode: normalizedMode };
17435
18006
  }) : undefined;
17436
18007
  const apps = Array.isArray(options["app"]) ? options["app"].map((value) => {
17437
18008
  const [name, manager, packageName] = String(value).split(":");
@@ -17459,65 +18030,55 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
17459
18030
  });
17460
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) => {
17461
18032
  const result = listApps(options.machine);
17462
- const rendered = JSON.stringify(result, null, 2);
17463
- 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);
17464
18042
  });
17465
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) => {
17466
18044
  const result = buildAppsPlan(options.machine);
17467
- const rendered = JSON.stringify(result, null, 2);
17468
- console.log(options.json ? rendered : rendered);
18045
+ console.log(options.json ? JSON.stringify(result, null, 2) : JSON.stringify(result, null, 2));
17469
18046
  });
17470
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) => {
17471
18048
  const result = runAppsInstall(options.machine, { apply: true, yes: options.yes });
17472
- const rendered = JSON.stringify(result, null, 2);
17473
- console.log(options.json ? rendered : rendered);
18049
+ console.log(options.json ? JSON.stringify(result, null, 2) : JSON.stringify(result, null, 2));
17474
18050
  });
17475
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) => {
17476
18052
  const result = options.apply ? runSetup(options.machine, { apply: true, yes: options.yes }) : buildSetupPlan(options.machine);
17477
- if (options.json) {
17478
- console.log(JSON.stringify(result, null, 2));
17479
- return;
17480
- }
17481
18053
  console.log(JSON.stringify(result, null, 2));
17482
18054
  });
17483
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) => {
17484
18056
  const result = options.apply ? runSync(options.machine, { apply: true, yes: options.yes }) : buildSyncPlan(options.machine);
17485
- if (options.json) {
17486
- console.log(JSON.stringify(result, null, 2));
17487
- return;
17488
- }
17489
18057
  console.log(JSON.stringify(result, null, 2));
17490
18058
  });
17491
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) => {
17492
18060
  const result = diffMachines(options.left, options.right);
17493
- if (options.json) {
17494
- console.log(JSON.stringify(result, null, 2));
17495
- return;
17496
- }
17497
18061
  console.log(JSON.stringify(result, null, 2));
17498
18062
  });
17499
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) => {
17500
18064
  const result = options.apply ? runBackup(options.bucket, options.prefix, { apply: true, yes: options.yes }) : buildBackupPlan(options.bucket, options.prefix);
17501
- const rendered = JSON.stringify(result, null, 2);
17502
- console.log(options.json ? rendered : rendered);
18065
+ console.log(JSON.stringify(result, null, 2));
17503
18066
  });
17504
18067
  var certCommand = program2.command("cert").description("Manage mkcert-based local SSL certificates");
17505
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) => {
17506
18069
  const result = options.apply ? runCertPlan(domains, { apply: true, yes: options.yes }) : buildCertPlan(domains);
17507
- const rendered = JSON.stringify(result, null, 2);
17508
- console.log(options.json ? rendered : rendered);
18070
+ console.log(JSON.stringify(result, null, 2));
17509
18071
  });
17510
18072
  var dnsCommand = program2.command("dns").description("Manage local domain mappings");
17511
- var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
17512
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) => {
17513
- const result = addDomainMapping(options.domain, Number.parseInt(options.port, 10), options.targetHost);
17514
- const rendered = JSON.stringify(result, null, 2);
17515
- 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));
17516
18079
  });
17517
- dnsCommand.command("list").description("List saved local domain mappings").option("-j, --json", "Print JSON output", false).action((options) => {
17518
- const result = listDomainMappings();
17519
- const rendered = JSON.stringify(result, null, 2);
17520
- 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));
17521
18082
  });
17522
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) => {
17523
18084
  const result = addNotificationChannel({
@@ -17527,41 +18088,50 @@ notificationsCommand.command("add").description("Add or replace a notification c
17527
18088
  events: options.event,
17528
18089
  enabled: !options.disabled
17529
18090
  });
17530
- const rendered = JSON.stringify(result, null, 2);
17531
- console.log(options.json ? rendered : rendered);
18091
+ printJsonOrText(result, renderNotificationConfigResult(result), options.json);
17532
18092
  });
17533
18093
  notificationsCommand.command("list").description("List configured notification channels").option("-j, --json", "Print JSON output", false).action((options) => {
17534
18094
  const result = listNotificationChannels();
17535
- const rendered = JSON.stringify(result, null, 2);
17536
- console.log(options.json ? rendered : rendered);
18095
+ printJsonOrText(result, renderNotificationConfigResult(result), options.json);
17537
18096
  });
17538
- 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) => {
17539
- 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, {
17540
18099
  apply: options.apply,
17541
18100
  yes: options.yes
17542
18101
  });
17543
- const rendered = JSON.stringify(result, null, 2);
17544
- 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);
17545
18107
  });
17546
18108
  notificationsCommand.command("remove").description("Remove a notification channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action((id, options) => {
17547
18109
  const result = removeNotificationChannel(id);
17548
- const rendered = JSON.stringify(result, null, 2);
17549
- 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);
17550
18119
  });
17551
- 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) => {
17552
- const result = renderDomainMapping(domain);
17553
- const rendered = JSON.stringify(result, null, 2);
17554
- 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));
17555
18127
  });
17556
- 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) => {
17557
18129
  const result = options.apply ? runClaudeInstall(options.machine, options.tool, { apply: true, yes: options.yes }) : buildClaudeInstallPlan(options.machine, options.tool);
17558
- const rendered = JSON.stringify(result, null, 2);
17559
- console.log(options.json ? rendered : rendered);
18130
+ console.log(JSON.stringify(result, null, 2));
17560
18131
  });
17561
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) => {
17562
18133
  const result = options.apply ? runTailscaleInstall(options.machine, { apply: true, yes: options.yes }) : buildTailscaleInstallPlan(options.machine);
17563
- const rendered = JSON.stringify(result, null, 2);
17564
- console.log(options.json ? rendered : rendered);
18134
+ console.log(JSON.stringify(result, null, 2));
17565
18135
  });
17566
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) => {
17567
18137
  if (options.json) {
@@ -17572,27 +18142,27 @@ program2.command("ssh").description("Choose the best SSH route for a machine").r
17572
18142
  });
17573
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) => {
17574
18144
  const result = listPorts(options.machine);
17575
- const rendered = JSON.stringify(result, null, 2);
17576
- console.log(options.json ? rendered : rendered);
18145
+ console.log(JSON.stringify(result, null, 2));
17577
18146
  });
17578
18147
  program2.command("status").description("Print local machine and storage status").option("-j, --json", "Print JSON output", false).action((options) => {
17579
18148
  const status = getStatus();
17580
- const rendered = JSON.stringify(status, null, 2);
17581
- 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);
17582
18158
  });
17583
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) => {
17584
- const info = getServeInfo({
17585
- host: options.host,
17586
- port: Number.parseInt(options.port, 10)
17587
- });
18160
+ const info = getServeInfo({ host: options.host, port: parseIntegerOption(options.port, "port", { min: 1, max: 65535 }) });
17588
18161
  if (options.json) {
17589
18162
  console.log(JSON.stringify(info, null, 2));
17590
18163
  return;
17591
18164
  }
17592
- const server = startDashboardServer({
17593
- host: info.host,
17594
- port: info.port
17595
- });
18165
+ const server = startDashboardServer({ host: info.host, port: info.port });
17596
18166
  console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
17597
18167
  });
17598
18168
  await program2.parseAsync(process.argv);