@hasna/machines 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -8
- package/dist/cli/index.js +966 -151
- package/dist/cli-utils.d.ts +7 -0
- package/dist/cli-utils.d.ts.map +1 -0
- package/dist/commands/apps.d.ts +3 -1
- package/dist/commands/apps.d.ts.map +1 -1
- package/dist/commands/clipboard-server.d.ts +17 -0
- package/dist/commands/clipboard-server.d.ts.map +1 -0
- package/dist/commands/clipboard.d.ts +19 -0
- package/dist/commands/clipboard.d.ts.map +1 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/install-claude.d.ts +3 -1
- package/dist/commands/install-claude.d.ts.map +1 -1
- package/dist/commands/notifications.d.ts +5 -2
- package/dist/commands/notifications.d.ts.map +1 -1
- package/dist/commands/self-test.d.ts +3 -0
- package/dist/commands/self-test.d.ts.map +1 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/cross-project-types.d.ts +26 -0
- package/dist/cross-project-types.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +751 -318
- package/dist/mcp/index.js +1893 -1516
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/paths.d.ts +2 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/remote.d.ts +9 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -6599,6 +6599,12 @@ function getManifestPath() {
|
|
|
6599
6599
|
function getNotificationsPath() {
|
|
6600
6600
|
return process.env["HASNA_MACHINES_NOTIFICATIONS_PATH"] || join2(getDataDir(), "notifications.json");
|
|
6601
6601
|
}
|
|
6602
|
+
function getClipboardKeyPath() {
|
|
6603
|
+
return process.env["HASNA_MACHINES_CLIPBOARD_KEY_PATH"] || join2(getDataDir(), "clipboard.key");
|
|
6604
|
+
}
|
|
6605
|
+
function getClipboardHistoryPath() {
|
|
6606
|
+
return process.env["HASNA_MACHINES_CLIPBOARD_HISTORY_PATH"] || join2(getDataDir(), "clipboard-history.json");
|
|
6607
|
+
}
|
|
6602
6608
|
function ensureParentDir(filePath) {
|
|
6603
6609
|
if (filePath === ":memory:")
|
|
6604
6610
|
return;
|
|
@@ -16698,17 +16704,95 @@ function diffMachines(leftMachineId, rightMachineId) {
|
|
|
16698
16704
|
};
|
|
16699
16705
|
}
|
|
16700
16706
|
|
|
16707
|
+
// src/remote.ts
|
|
16708
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
16709
|
+
|
|
16710
|
+
// src/commands/ssh.ts
|
|
16711
|
+
import { spawnSync } from "child_process";
|
|
16712
|
+
function envReachableHosts() {
|
|
16713
|
+
const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
|
|
16714
|
+
return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
|
|
16715
|
+
}
|
|
16716
|
+
function isReachable(host) {
|
|
16717
|
+
const overrides = envReachableHosts();
|
|
16718
|
+
if (overrides.size > 0) {
|
|
16719
|
+
return overrides.has(host);
|
|
16720
|
+
}
|
|
16721
|
+
const probe = spawnSync("bash", ["-lc", `getent hosts ${host} >/dev/null 2>&1 || ping -c 1 -W 1 ${host} >/dev/null 2>&1`], {
|
|
16722
|
+
stdio: "ignore"
|
|
16723
|
+
});
|
|
16724
|
+
return probe.status === 0;
|
|
16725
|
+
}
|
|
16726
|
+
function resolveSshTarget(machineId) {
|
|
16727
|
+
const machine = getManifestMachine(machineId);
|
|
16728
|
+
if (!machine) {
|
|
16729
|
+
throw new Error(`Machine not found in manifest: ${machineId}`);
|
|
16730
|
+
}
|
|
16731
|
+
const current = detectCurrentMachineManifest();
|
|
16732
|
+
if (machine.id === current.id) {
|
|
16733
|
+
return {
|
|
16734
|
+
machineId,
|
|
16735
|
+
target: "localhost",
|
|
16736
|
+
route: "local"
|
|
16737
|
+
};
|
|
16738
|
+
}
|
|
16739
|
+
const lanTarget = machine.sshAddress || machine.hostname || machine.id;
|
|
16740
|
+
const tailscaleTarget = machine.tailscaleName || machine.hostname || machine.id;
|
|
16741
|
+
const route = isReachable(lanTarget) ? "lan" : "tailscale";
|
|
16742
|
+
return {
|
|
16743
|
+
machineId,
|
|
16744
|
+
target: route === "lan" ? lanTarget : tailscaleTarget,
|
|
16745
|
+
route
|
|
16746
|
+
};
|
|
16747
|
+
}
|
|
16748
|
+
function buildSshCommand(machineId, remoteCommand) {
|
|
16749
|
+
const resolved = resolveSshTarget(machineId);
|
|
16750
|
+
return remoteCommand ? `ssh ${resolved.target} ${JSON.stringify(remoteCommand)}` : `ssh ${resolved.target}`;
|
|
16751
|
+
}
|
|
16752
|
+
|
|
16753
|
+
// src/remote.ts
|
|
16754
|
+
function runMachineCommand(machineId, command) {
|
|
16755
|
+
const localMachineId = getLocalMachineId();
|
|
16756
|
+
const isLocal = machineId === localMachineId;
|
|
16757
|
+
const route = isLocal ? "local" : resolveSshTarget(machineId).route;
|
|
16758
|
+
const shellCommand = isLocal ? command : buildSshCommand(machineId, command);
|
|
16759
|
+
const result = spawnSync2("bash", ["-lc", shellCommand], {
|
|
16760
|
+
encoding: "utf8",
|
|
16761
|
+
env: process.env
|
|
16762
|
+
});
|
|
16763
|
+
return {
|
|
16764
|
+
machineId,
|
|
16765
|
+
source: route,
|
|
16766
|
+
stdout: result.stdout || "",
|
|
16767
|
+
stderr: result.stderr || "",
|
|
16768
|
+
exitCode: result.status ?? 1
|
|
16769
|
+
};
|
|
16770
|
+
}
|
|
16771
|
+
|
|
16701
16772
|
// src/commands/apps.ts
|
|
16702
16773
|
function getPackageName(app) {
|
|
16703
16774
|
return app.packageName || app.name;
|
|
16704
16775
|
}
|
|
16776
|
+
function getAppManager(machine, app) {
|
|
16777
|
+
if (app.manager)
|
|
16778
|
+
return app.manager;
|
|
16779
|
+
if (machine.platform === "macos")
|
|
16780
|
+
return "brew";
|
|
16781
|
+
if (machine.platform === "windows")
|
|
16782
|
+
return "winget";
|
|
16783
|
+
return "apt";
|
|
16784
|
+
}
|
|
16785
|
+
function shellQuote(value) {
|
|
16786
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
16787
|
+
}
|
|
16705
16788
|
function buildAppCommand(machine, app) {
|
|
16706
16789
|
const packageName = getPackageName(app);
|
|
16707
|
-
|
|
16790
|
+
const manager = getAppManager(machine, app);
|
|
16791
|
+
if (manager === "custom") {
|
|
16708
16792
|
return packageName;
|
|
16709
16793
|
}
|
|
16710
16794
|
if (machine.platform === "macos") {
|
|
16711
|
-
if (
|
|
16795
|
+
if (manager === "cask") {
|
|
16712
16796
|
return `brew install --cask ${packageName}`;
|
|
16713
16797
|
}
|
|
16714
16798
|
return `brew install ${packageName}`;
|
|
@@ -16718,18 +16802,48 @@ function buildAppCommand(machine, app) {
|
|
|
16718
16802
|
}
|
|
16719
16803
|
return `sudo apt-get install -y ${packageName}`;
|
|
16720
16804
|
}
|
|
16805
|
+
function buildAppProbeCommand(machine, app) {
|
|
16806
|
+
const packageName = shellQuote(getPackageName(app));
|
|
16807
|
+
const manager = getAppManager(machine, app);
|
|
16808
|
+
if (manager === "custom") {
|
|
16809
|
+
return `if command -v ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion=custom\\n'; else printf 'installed=0\\n'; fi`;
|
|
16810
|
+
}
|
|
16811
|
+
if (machine.platform === "macos") {
|
|
16812
|
+
if (manager === "cask") {
|
|
16813
|
+
return `if brew list --cask ${packageName} >/dev/null 2>&1; then printf 'installed=1\\nversion=installed\\n'; else printf 'installed=0\\n'; fi`;
|
|
16814
|
+
}
|
|
16815
|
+
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`;
|
|
16816
|
+
}
|
|
16817
|
+
if (machine.platform === "windows") {
|
|
16818
|
+
return `if winget list --id ${packageName} --exact >/dev/null 2>&1; then printf 'installed=1\\nversion=installed\\n'; else printf 'installed=0\\n'; fi`;
|
|
16819
|
+
}
|
|
16820
|
+
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`;
|
|
16821
|
+
}
|
|
16721
16822
|
function buildAppSteps(machine) {
|
|
16722
16823
|
return (machine.apps || []).map((app) => ({
|
|
16723
16824
|
id: `app-${app.name}`,
|
|
16724
16825
|
title: `Install ${app.name} on ${machine.id}`,
|
|
16725
16826
|
command: buildAppCommand(machine, app),
|
|
16726
|
-
manager: app
|
|
16827
|
+
manager: getAppManager(machine, app) === "custom" ? "custom" : machine.platform === "macos" ? "brew" : machine.platform === "windows" ? "custom" : "apt",
|
|
16727
16828
|
privileged: machine.platform === "linux"
|
|
16728
16829
|
}));
|
|
16729
16830
|
}
|
|
16730
16831
|
function resolveMachine(machineId) {
|
|
16731
16832
|
return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
|
|
16732
16833
|
}
|
|
16834
|
+
function parseProbeOutput(app, machine, stdout) {
|
|
16835
|
+
const lines = stdout.trim().split(`
|
|
16836
|
+
`).filter(Boolean);
|
|
16837
|
+
const installedLine = lines.find((line) => line.startsWith("installed="));
|
|
16838
|
+
const versionLine = lines.find((line) => line.startsWith("version="));
|
|
16839
|
+
return {
|
|
16840
|
+
name: app.name,
|
|
16841
|
+
packageName: getPackageName(app),
|
|
16842
|
+
manager: getAppManager(machine, app),
|
|
16843
|
+
installed: installedLine === "installed=1",
|
|
16844
|
+
version: versionLine?.slice("version=".length) || undefined
|
|
16845
|
+
};
|
|
16846
|
+
}
|
|
16733
16847
|
function listApps(machineId) {
|
|
16734
16848
|
const machine = resolveMachine(machineId);
|
|
16735
16849
|
return {
|
|
@@ -16746,6 +16860,26 @@ function buildAppsPlan(machineId) {
|
|
|
16746
16860
|
executed: 0
|
|
16747
16861
|
};
|
|
16748
16862
|
}
|
|
16863
|
+
function getAppsStatus(machineId) {
|
|
16864
|
+
const machine = resolveMachine(machineId);
|
|
16865
|
+
const apps = (machine.apps || []).map((app) => {
|
|
16866
|
+
const probe = runMachineCommand(machine.id, buildAppProbeCommand(machine, app));
|
|
16867
|
+
return parseProbeOutput(app, machine, probe.stdout);
|
|
16868
|
+
});
|
|
16869
|
+
return {
|
|
16870
|
+
machineId: machine.id,
|
|
16871
|
+
source: apps.length > 0 ? runMachineCommand(machine.id, "true").source : machine.id === detectCurrentMachineManifest().id ? "local" : runMachineCommand(machine.id, "true").source,
|
|
16872
|
+
apps
|
|
16873
|
+
};
|
|
16874
|
+
}
|
|
16875
|
+
function diffApps(machineId) {
|
|
16876
|
+
const status = getAppsStatus(machineId);
|
|
16877
|
+
return {
|
|
16878
|
+
...status,
|
|
16879
|
+
missing: status.apps.filter((app) => !app.installed).map((app) => app.name),
|
|
16880
|
+
installed: status.apps.filter((app) => app.installed).map((app) => app.name)
|
|
16881
|
+
};
|
|
16882
|
+
}
|
|
16749
16883
|
function runAppsInstall(machineId, options = {}) {
|
|
16750
16884
|
const plan = buildAppsPlan(machineId);
|
|
16751
16885
|
if (!options.apply)
|
|
@@ -16779,6 +16913,13 @@ var AI_CLI_PACKAGES = {
|
|
|
16779
16913
|
codex: "@openai/codex",
|
|
16780
16914
|
gemini: "@google/gemini-cli"
|
|
16781
16915
|
};
|
|
16916
|
+
function getToolBinary(tool) {
|
|
16917
|
+
if (tool === "claude")
|
|
16918
|
+
return process.env["HASNA_MACHINES_CLAUDE_BINARY"] || "claude";
|
|
16919
|
+
if (tool === "codex")
|
|
16920
|
+
return process.env["HASNA_MACHINES_CODEX_BINARY"] || "codex";
|
|
16921
|
+
return process.env["HASNA_MACHINES_GEMINI_BINARY"] || "gemini";
|
|
16922
|
+
}
|
|
16782
16923
|
function normalizeTools(tools) {
|
|
16783
16924
|
if (!tools || tools.length === 0) {
|
|
16784
16925
|
return ["claude", "codex", "gemini"];
|
|
@@ -16798,8 +16939,27 @@ function buildInstallSteps(machine, tools) {
|
|
|
16798
16939
|
manager: "bun"
|
|
16799
16940
|
}));
|
|
16800
16941
|
}
|
|
16942
|
+
function resolveMachine2(machineId) {
|
|
16943
|
+
return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
|
|
16944
|
+
}
|
|
16945
|
+
function buildProbeCommand(tool) {
|
|
16946
|
+
const binary = getToolBinary(tool);
|
|
16947
|
+
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`;
|
|
16948
|
+
}
|
|
16949
|
+
function parseProbe(tool, stdout) {
|
|
16950
|
+
const lines = stdout.trim().split(`
|
|
16951
|
+
`).filter(Boolean);
|
|
16952
|
+
const installedLine = lines.find((line) => line.startsWith("installed="));
|
|
16953
|
+
const versionLine = lines.find((line) => line.startsWith("version="));
|
|
16954
|
+
return {
|
|
16955
|
+
tool,
|
|
16956
|
+
packageName: AI_CLI_PACKAGES[tool],
|
|
16957
|
+
installed: installedLine === "installed=1",
|
|
16958
|
+
version: versionLine?.slice("version=".length) || undefined
|
|
16959
|
+
};
|
|
16960
|
+
}
|
|
16801
16961
|
function buildClaudeInstallPlan(machineId, tools) {
|
|
16802
|
-
const machine = (machineId
|
|
16962
|
+
const machine = resolveMachine2(machineId);
|
|
16803
16963
|
return {
|
|
16804
16964
|
machineId: machine.id,
|
|
16805
16965
|
mode: "plan",
|
|
@@ -16807,6 +16967,24 @@ function buildClaudeInstallPlan(machineId, tools) {
|
|
|
16807
16967
|
executed: 0
|
|
16808
16968
|
};
|
|
16809
16969
|
}
|
|
16970
|
+
function getClaudeCliStatus(machineId, tools) {
|
|
16971
|
+
const machine = resolveMachine2(machineId);
|
|
16972
|
+
const normalizedTools = normalizeTools(tools);
|
|
16973
|
+
const route = runMachineCommand(machine.id, "true").source;
|
|
16974
|
+
return {
|
|
16975
|
+
machineId: machine.id,
|
|
16976
|
+
source: route,
|
|
16977
|
+
tools: normalizedTools.map((tool) => parseProbe(tool, runMachineCommand(machine.id, buildProbeCommand(tool)).stdout))
|
|
16978
|
+
};
|
|
16979
|
+
}
|
|
16980
|
+
function diffClaudeCli(machineId, tools) {
|
|
16981
|
+
const status = getClaudeCliStatus(machineId, tools);
|
|
16982
|
+
return {
|
|
16983
|
+
...status,
|
|
16984
|
+
missing: status.tools.filter((tool) => !tool.installed).map((tool) => tool.tool),
|
|
16985
|
+
installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
|
|
16986
|
+
};
|
|
16987
|
+
}
|
|
16810
16988
|
function runClaudeInstall(machineId, tools, options = {}) {
|
|
16811
16989
|
const plan = buildClaudeInstallPlan(machineId, tools);
|
|
16812
16990
|
if (!options.apply)
|
|
@@ -16919,6 +17097,138 @@ var notificationConfigSchema = exports_external.object({
|
|
|
16919
17097
|
function sortChannels(channels) {
|
|
16920
17098
|
return [...channels].sort((left, right) => left.id.localeCompare(right.id));
|
|
16921
17099
|
}
|
|
17100
|
+
function shellQuote2(value) {
|
|
17101
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
17102
|
+
}
|
|
17103
|
+
function hasCommand(binary) {
|
|
17104
|
+
const result = Bun.spawnSync(["bash", "-lc", `command -v ${binary} >/dev/null 2>&1`], {
|
|
17105
|
+
stdout: "ignore",
|
|
17106
|
+
stderr: "ignore",
|
|
17107
|
+
env: process.env
|
|
17108
|
+
});
|
|
17109
|
+
return result.exitCode === 0;
|
|
17110
|
+
}
|
|
17111
|
+
function buildNotificationPreview(channel, event, message) {
|
|
17112
|
+
if (channel.type === "email") {
|
|
17113
|
+
return `send email to ${channel.target}: [${event}] ${message}`;
|
|
17114
|
+
}
|
|
17115
|
+
if (channel.type === "webhook") {
|
|
17116
|
+
return `POST ${channel.target} with payload {"event":"${event}","message":"${message}"}`;
|
|
17117
|
+
}
|
|
17118
|
+
return `${channel.target} --event ${event} --message ${JSON.stringify(message)}`;
|
|
17119
|
+
}
|
|
17120
|
+
async function dispatchEmail(channel, event, message) {
|
|
17121
|
+
const subject = `[${event}] machines notification`;
|
|
17122
|
+
const body = `To: ${channel.target}
|
|
17123
|
+
Subject: ${subject}
|
|
17124
|
+
Content-Type: text/plain; charset=utf-8
|
|
17125
|
+
|
|
17126
|
+
${message}
|
|
17127
|
+
`;
|
|
17128
|
+
if (hasCommand("sendmail")) {
|
|
17129
|
+
const result = Bun.spawnSync(["bash", "-lc", "sendmail -t"], {
|
|
17130
|
+
stdin: new TextEncoder().encode(body),
|
|
17131
|
+
stdout: "pipe",
|
|
17132
|
+
stderr: "pipe",
|
|
17133
|
+
env: process.env
|
|
17134
|
+
});
|
|
17135
|
+
if (result.exitCode !== 0) {
|
|
17136
|
+
throw new Error(result.stderr.toString().trim() || `sendmail exited with ${result.exitCode}`);
|
|
17137
|
+
}
|
|
17138
|
+
return {
|
|
17139
|
+
channelId: channel.id,
|
|
17140
|
+
event,
|
|
17141
|
+
delivered: true,
|
|
17142
|
+
transport: channel.type,
|
|
17143
|
+
detail: `Delivered via sendmail to ${channel.target}`
|
|
17144
|
+
};
|
|
17145
|
+
}
|
|
17146
|
+
if (hasCommand("mail")) {
|
|
17147
|
+
const command = `printf %s ${shellQuote2(message)} | mail -s ${shellQuote2(subject)} ${shellQuote2(channel.target)}`;
|
|
17148
|
+
const result = Bun.spawnSync(["bash", "-lc", command], {
|
|
17149
|
+
stdout: "pipe",
|
|
17150
|
+
stderr: "pipe",
|
|
17151
|
+
env: process.env
|
|
17152
|
+
});
|
|
17153
|
+
if (result.exitCode !== 0) {
|
|
17154
|
+
throw new Error(result.stderr.toString().trim() || `mail exited with ${result.exitCode}`);
|
|
17155
|
+
}
|
|
17156
|
+
return {
|
|
17157
|
+
channelId: channel.id,
|
|
17158
|
+
event,
|
|
17159
|
+
delivered: true,
|
|
17160
|
+
transport: channel.type,
|
|
17161
|
+
detail: `Delivered via mail to ${channel.target}`
|
|
17162
|
+
};
|
|
17163
|
+
}
|
|
17164
|
+
throw new Error("No local email transport available. Install sendmail or mail.");
|
|
17165
|
+
}
|
|
17166
|
+
async function dispatchWebhook(channel, event, message) {
|
|
17167
|
+
const response = await fetch(channel.target, {
|
|
17168
|
+
method: "POST",
|
|
17169
|
+
headers: {
|
|
17170
|
+
"content-type": "application/json"
|
|
17171
|
+
},
|
|
17172
|
+
body: JSON.stringify({
|
|
17173
|
+
channelId: channel.id,
|
|
17174
|
+
event,
|
|
17175
|
+
message,
|
|
17176
|
+
sentAt: new Date().toISOString()
|
|
17177
|
+
})
|
|
17178
|
+
});
|
|
17179
|
+
if (!response.ok) {
|
|
17180
|
+
const text = await response.text();
|
|
17181
|
+
throw new Error(`Webhook responded ${response.status}: ${text || response.statusText}`);
|
|
17182
|
+
}
|
|
17183
|
+
return {
|
|
17184
|
+
channelId: channel.id,
|
|
17185
|
+
event,
|
|
17186
|
+
delivered: true,
|
|
17187
|
+
transport: channel.type,
|
|
17188
|
+
detail: `Webhook accepted with HTTP ${response.status}`
|
|
17189
|
+
};
|
|
17190
|
+
}
|
|
17191
|
+
async function dispatchCommand(channel, event, message) {
|
|
17192
|
+
const result = Bun.spawnSync(["bash", "-lc", channel.target], {
|
|
17193
|
+
stdout: "pipe",
|
|
17194
|
+
stderr: "pipe",
|
|
17195
|
+
env: {
|
|
17196
|
+
...process.env,
|
|
17197
|
+
HASNA_MACHINES_NOTIFICATION_CHANNEL: channel.id,
|
|
17198
|
+
HASNA_MACHINES_NOTIFICATION_EVENT: event,
|
|
17199
|
+
HASNA_MACHINES_NOTIFICATION_MESSAGE: message
|
|
17200
|
+
}
|
|
17201
|
+
});
|
|
17202
|
+
if (result.exitCode !== 0) {
|
|
17203
|
+
throw new Error(result.stderr.toString().trim() || `command exited with ${result.exitCode}`);
|
|
17204
|
+
}
|
|
17205
|
+
const stdout = result.stdout.toString().trim();
|
|
17206
|
+
return {
|
|
17207
|
+
channelId: channel.id,
|
|
17208
|
+
event,
|
|
17209
|
+
delivered: true,
|
|
17210
|
+
transport: channel.type,
|
|
17211
|
+
detail: stdout || "Command completed successfully"
|
|
17212
|
+
};
|
|
17213
|
+
}
|
|
17214
|
+
async function dispatchChannel(channel, event, message) {
|
|
17215
|
+
if (!channel.enabled) {
|
|
17216
|
+
return {
|
|
17217
|
+
channelId: channel.id,
|
|
17218
|
+
event,
|
|
17219
|
+
delivered: false,
|
|
17220
|
+
transport: channel.type,
|
|
17221
|
+
detail: "Channel is disabled"
|
|
17222
|
+
};
|
|
17223
|
+
}
|
|
17224
|
+
if (channel.type === "email") {
|
|
17225
|
+
return dispatchEmail(channel, event, message);
|
|
17226
|
+
}
|
|
17227
|
+
if (channel.type === "webhook") {
|
|
17228
|
+
return dispatchWebhook(channel, event, message);
|
|
17229
|
+
}
|
|
17230
|
+
return dispatchCommand(channel, event, message);
|
|
17231
|
+
}
|
|
16922
17232
|
function getDefaultNotificationConfig() {
|
|
16923
17233
|
return {
|
|
16924
17234
|
version: 1,
|
|
@@ -16962,16 +17272,34 @@ function removeNotificationChannel(channelId) {
|
|
|
16962
17272
|
channels: config.channels.filter((channel) => channel.id !== channelId)
|
|
16963
17273
|
});
|
|
16964
17274
|
}
|
|
16965
|
-
function
|
|
16966
|
-
|
|
16967
|
-
|
|
16968
|
-
|
|
16969
|
-
|
|
16970
|
-
return
|
|
17275
|
+
async function dispatchNotificationEvent(event, message, options = {}) {
|
|
17276
|
+
const channels = readNotificationConfig().channels.filter((channel) => {
|
|
17277
|
+
if (options.channelId && channel.id !== options.channelId) {
|
|
17278
|
+
return false;
|
|
17279
|
+
}
|
|
17280
|
+
return channel.events.includes(event) || event === "manual.test";
|
|
17281
|
+
});
|
|
17282
|
+
const deliveries = [];
|
|
17283
|
+
for (const channel of channels) {
|
|
17284
|
+
try {
|
|
17285
|
+
deliveries.push(await dispatchChannel(channel, event, message));
|
|
17286
|
+
} catch (error) {
|
|
17287
|
+
deliveries.push({
|
|
17288
|
+
channelId: channel.id,
|
|
17289
|
+
event,
|
|
17290
|
+
delivered: false,
|
|
17291
|
+
transport: channel.type,
|
|
17292
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
17293
|
+
});
|
|
17294
|
+
}
|
|
16971
17295
|
}
|
|
16972
|
-
return
|
|
17296
|
+
return {
|
|
17297
|
+
event,
|
|
17298
|
+
message,
|
|
17299
|
+
deliveries
|
|
17300
|
+
};
|
|
16973
17301
|
}
|
|
16974
|
-
function testNotificationChannel(channelId, event = "manual.test", message = "machines notification test", options = {}) {
|
|
17302
|
+
async function testNotificationChannel(channelId, event = "manual.test", message = "machines notification test", options = {}) {
|
|
16975
17303
|
const channel = readNotificationConfig().channels.find((entry) => entry.id === channelId);
|
|
16976
17304
|
if (!channel) {
|
|
16977
17305
|
throw new Error(`Notification channel not found: ${channelId}`);
|
|
@@ -16982,77 +17310,25 @@ function testNotificationChannel(channelId, event = "manual.test", message = "ma
|
|
|
16982
17310
|
channelId,
|
|
16983
17311
|
mode: "plan",
|
|
16984
17312
|
delivered: false,
|
|
16985
|
-
preview
|
|
17313
|
+
preview,
|
|
17314
|
+
detail: "Preview only"
|
|
16986
17315
|
};
|
|
16987
17316
|
}
|
|
16988
17317
|
if (!options.yes) {
|
|
16989
17318
|
throw new Error("Notification test execution requires --yes.");
|
|
16990
17319
|
}
|
|
16991
|
-
|
|
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
|
-
}
|
|
17320
|
+
const delivery = await dispatchChannel(channel, event, message);
|
|
17001
17321
|
return {
|
|
17002
17322
|
channelId,
|
|
17003
17323
|
mode: "apply",
|
|
17004
|
-
delivered:
|
|
17005
|
-
preview
|
|
17324
|
+
delivered: delivery.delivered,
|
|
17325
|
+
preview,
|
|
17326
|
+
detail: delivery.detail
|
|
17006
17327
|
};
|
|
17007
17328
|
}
|
|
17008
17329
|
|
|
17009
17330
|
// src/commands/ports.ts
|
|
17010
|
-
import { spawnSync as
|
|
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
|
|
17331
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
17056
17332
|
function parseSsOutput(output) {
|
|
17057
17333
|
return output.trim().split(`
|
|
17058
17334
|
`).map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
@@ -17094,7 +17370,7 @@ function listPorts(machineId) {
|
|
|
17094
17370
|
const isLocal = targetMachineId === getLocalMachineId();
|
|
17095
17371
|
const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
|
|
17096
17372
|
const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
|
|
17097
|
-
const result =
|
|
17373
|
+
const result = spawnSync3("bash", ["-lc", command], { encoding: "utf8" });
|
|
17098
17374
|
if (result.status !== 0) {
|
|
17099
17375
|
throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
|
|
17100
17376
|
}
|
|
@@ -17289,6 +17565,59 @@ function getStatus() {
|
|
|
17289
17565
|
};
|
|
17290
17566
|
}
|
|
17291
17567
|
|
|
17568
|
+
// src/commands/doctor.ts
|
|
17569
|
+
function makeCheck(id, status, summary, detail) {
|
|
17570
|
+
return { id, status, summary, detail };
|
|
17571
|
+
}
|
|
17572
|
+
function parseKeyValueOutput(stdout) {
|
|
17573
|
+
return Object.fromEntries(stdout.trim().split(`
|
|
17574
|
+
`).map((line) => line.split("=")).filter((parts) => parts.length === 2).map(([key, value]) => [key, value]));
|
|
17575
|
+
}
|
|
17576
|
+
function buildDoctorCommand() {
|
|
17577
|
+
return [
|
|
17578
|
+
'data_dir="${HASNA_MACHINES_DIR:-$HOME/.hasna/machines}"',
|
|
17579
|
+
'manifest_path="${HASNA_MACHINES_MANIFEST_PATH:-$data_dir/machines.json}"',
|
|
17580
|
+
'db_path="${HASNA_MACHINES_DB_PATH:-$data_dir/machines.db}"',
|
|
17581
|
+
'notifications_path="${HASNA_MACHINES_NOTIFICATIONS_PATH:-$data_dir/notifications.json}"',
|
|
17582
|
+
`printf 'manifest_path=%s\\n' "$manifest_path"`,
|
|
17583
|
+
`printf 'db_path=%s\\n' "$db_path"`,
|
|
17584
|
+
`printf 'notifications_path=%s\\n' "$notifications_path"`,
|
|
17585
|
+
`printf 'manifest_exists=%s\\n' "$(test -e "$manifest_path" && printf yes || printf no)"`,
|
|
17586
|
+
`printf 'db_exists=%s\\n' "$(test -e "$db_path" && printf yes || printf no)"`,
|
|
17587
|
+
`printf 'notifications_exists=%s\\n' "$(test -e "$notifications_path" && printf yes || printf no)"`,
|
|
17588
|
+
`printf 'bun=%s\\n' "$(bun --version 2>/dev/null || printf missing)"`,
|
|
17589
|
+
`printf 'ssh=%s\\n' "$(command -v ssh >/dev/null 2>&1 && printf ok || printf missing)"`,
|
|
17590
|
+
`printf 'machines=%s\\n' "$(command -v machines 2>/dev/null || printf missing)"`,
|
|
17591
|
+
`printf 'machines_agent=%s\\n' "$(command -v machines-agent 2>/dev/null || printf missing)"`,
|
|
17592
|
+
`printf 'machines_mcp=%s\\n' "$(command -v machines-mcp 2>/dev/null || printf missing)"`
|
|
17593
|
+
].join("; ");
|
|
17594
|
+
}
|
|
17595
|
+
function runDoctor(machineId = getLocalMachineId()) {
|
|
17596
|
+
const manifest = readManifest();
|
|
17597
|
+
const commandChecks = runMachineCommand(machineId, buildDoctorCommand());
|
|
17598
|
+
const details = parseKeyValueOutput(commandChecks.stdout);
|
|
17599
|
+
const machineInManifest = manifest.machines.find((machine) => machine.id === machineId);
|
|
17600
|
+
const checks = [
|
|
17601
|
+
makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(machineInManifest) : `No manifest entry for ${machineId}`),
|
|
17602
|
+
makeCheck("manifest-path", details["manifest_exists"] === "yes" ? "ok" : "warn", "Manifest path check", `${details["manifest_path"] || "unknown"} ${details["manifest_exists"] === "yes" ? "exists" : "missing"}`),
|
|
17603
|
+
makeCheck("db-path", details["db_exists"] === "yes" ? "ok" : "warn", "DB path check", `${details["db_path"] || "unknown"} ${details["db_exists"] === "yes" ? "exists" : "missing"}`),
|
|
17604
|
+
makeCheck("notifications-path", details["notifications_exists"] === "yes" ? "ok" : "warn", "Notifications path check", `${details["notifications_path"] || "unknown"} ${details["notifications_exists"] === "yes" ? "exists" : "missing"}`),
|
|
17605
|
+
makeCheck("bun", details["bun"] && details["bun"] !== "missing" ? "ok" : "fail", "Bun availability", details["bun"] || "missing"),
|
|
17606
|
+
makeCheck("machines-cli", details["machines"] && details["machines"] !== "missing" ? "ok" : "warn", "machines CLI availability", details["machines"] || "missing"),
|
|
17607
|
+
makeCheck("machines-agent-cli", details["machines_agent"] && details["machines_agent"] !== "missing" ? "ok" : "warn", "machines-agent availability", details["machines_agent"] || "missing"),
|
|
17608
|
+
makeCheck("machines-mcp-cli", details["machines_mcp"] && details["machines_mcp"] !== "missing" ? "ok" : "warn", "machines-mcp availability", details["machines_mcp"] || "missing"),
|
|
17609
|
+
makeCheck("ssh", details["ssh"] === "ok" ? "ok" : "warn", "SSH availability", details["ssh"] || "missing")
|
|
17610
|
+
];
|
|
17611
|
+
return {
|
|
17612
|
+
machineId,
|
|
17613
|
+
source: commandChecks.source,
|
|
17614
|
+
manifestPath: details["manifest_path"],
|
|
17615
|
+
dbPath: details["db_path"],
|
|
17616
|
+
notificationsPath: details["notifications_path"],
|
|
17617
|
+
checks
|
|
17618
|
+
};
|
|
17619
|
+
}
|
|
17620
|
+
|
|
17292
17621
|
// src/commands/serve.ts
|
|
17293
17622
|
function escapeHtml(value) {
|
|
17294
17623
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -17300,13 +17629,27 @@ function getServeInfo(options = {}) {
|
|
|
17300
17629
|
host,
|
|
17301
17630
|
port,
|
|
17302
17631
|
url: `http://${host}:${port}`,
|
|
17303
|
-
routes: [
|
|
17632
|
+
routes: [
|
|
17633
|
+
"/",
|
|
17634
|
+
"/health",
|
|
17635
|
+
"/api/status",
|
|
17636
|
+
"/api/manifest",
|
|
17637
|
+
"/api/notifications",
|
|
17638
|
+
"/api/doctor",
|
|
17639
|
+
"/api/self-test",
|
|
17640
|
+
"/api/apps/status",
|
|
17641
|
+
"/api/apps/diff",
|
|
17642
|
+
"/api/install-claude/status",
|
|
17643
|
+
"/api/install-claude/diff",
|
|
17644
|
+
"/api/notifications/test"
|
|
17645
|
+
]
|
|
17304
17646
|
};
|
|
17305
17647
|
}
|
|
17306
17648
|
function renderDashboardHtml() {
|
|
17307
17649
|
const status = getStatus();
|
|
17308
17650
|
const manifest = manifestList();
|
|
17309
17651
|
const notifications = listNotificationChannels();
|
|
17652
|
+
const doctor = runDoctor();
|
|
17310
17653
|
return `<!doctype html>
|
|
17311
17654
|
<html lang="en">
|
|
17312
17655
|
<head>
|
|
@@ -17322,21 +17665,26 @@ function renderDashboardHtml() {
|
|
|
17322
17665
|
.card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
|
|
17323
17666
|
.stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
|
|
17324
17667
|
table { width: 100%; border-collapse: collapse; }
|
|
17325
|
-
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; }
|
|
17668
|
+
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
|
|
17326
17669
|
code { color: #9ed0ff; }
|
|
17327
17670
|
.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; }
|
|
17671
|
+
.online, .ok { background: #12351f; color: #74f0a7; }
|
|
17672
|
+
.offline, .fail { background: #3b1a1a; color: #ff8c8c; }
|
|
17673
|
+
.unknown, .warn { background: #2f2b16; color: #ffd76a; }
|
|
17674
|
+
ul { margin: 8px 0 0; padding-left: 18px; }
|
|
17675
|
+
.muted { color: #9fb0d9; }
|
|
17676
|
+
.refresh { font-size: 12px; color: #6b7fa3; margin-left: auto; }
|
|
17677
|
+
.updated { transition: opacity 0.3s; }
|
|
17331
17678
|
</style>
|
|
17332
17679
|
</head>
|
|
17333
17680
|
<body>
|
|
17334
17681
|
<main>
|
|
17335
|
-
<h1>Machines Dashboard
|
|
17682
|
+
<h1>Machines Dashboard <span class="refresh" id="last-updated"></span></h1>
|
|
17336
17683
|
<div class="grid">
|
|
17337
17684
|
<section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
|
|
17338
17685
|
<section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
|
|
17339
17686
|
<section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
|
|
17687
|
+
<section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
|
|
17340
17688
|
</div>
|
|
17341
17689
|
|
|
17342
17690
|
<section class="card" style="margin-top:16px">
|
|
@@ -17354,21 +17702,119 @@ function renderDashboardHtml() {
|
|
|
17354
17702
|
</table>
|
|
17355
17703
|
</section>
|
|
17356
17704
|
|
|
17705
|
+
<section class="card" style="margin-top:16px">
|
|
17706
|
+
<h2>Doctor</h2>
|
|
17707
|
+
<table>
|
|
17708
|
+
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
|
|
17709
|
+
<tbody id="doctor-tbody">
|
|
17710
|
+
${doctor.checks.map((entry) => `<tr>
|
|
17711
|
+
<td>${escapeHtml(entry.summary)}</td>
|
|
17712
|
+
<td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
|
|
17713
|
+
<td class="muted">${escapeHtml(entry.detail)}</td>
|
|
17714
|
+
</tr>`).join("")}
|
|
17715
|
+
</tbody>
|
|
17716
|
+
</table>
|
|
17717
|
+
</section>
|
|
17718
|
+
|
|
17719
|
+
<section class="card" style="margin-top:16px">
|
|
17720
|
+
<h2>Apps</h2>
|
|
17721
|
+
<p class="muted">Use <code>/api/apps/status</code> for the full app inventory payload.</p>
|
|
17722
|
+
</section>
|
|
17723
|
+
|
|
17724
|
+
<section class="card" style="margin-top:16px">
|
|
17725
|
+
<h2>AI CLIs</h2>
|
|
17726
|
+
<p class="muted">Use <code>/api/install-claude/status</code> for the full CLI inventory payload.</p>
|
|
17727
|
+
</section>
|
|
17728
|
+
|
|
17729
|
+
<section class="card" style="margin-top:16px">
|
|
17730
|
+
<h2>Self Test</h2>
|
|
17731
|
+
<p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
|
|
17732
|
+
</section>
|
|
17733
|
+
|
|
17357
17734
|
<section class="card" style="margin-top:16px">
|
|
17358
17735
|
<h2>Manifest</h2>
|
|
17359
|
-
<pre>${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
|
|
17736
|
+
<pre id="manifest-json">${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
|
|
17360
17737
|
</section>
|
|
17361
17738
|
</main>
|
|
17739
|
+
<script>
|
|
17740
|
+
// Auto-refresh dashboard data every 15s
|
|
17741
|
+
const REFRESH_INTERVAL = 15000;
|
|
17742
|
+
async function refreshData() {
|
|
17743
|
+
try {
|
|
17744
|
+
const [statusRes, doctorRes] = await Promise.all([
|
|
17745
|
+
fetch("/api/status"),
|
|
17746
|
+
fetch("/api/doctor"),
|
|
17747
|
+
]);
|
|
17748
|
+
const status = await statusRes.json();
|
|
17749
|
+
const doctor = await doctorRes.json();
|
|
17750
|
+
|
|
17751
|
+
// Update stat cards
|
|
17752
|
+
const stats = document.querySelectorAll(".stat");
|
|
17753
|
+
if (stats[0]) stats[0].textContent = status.manifestMachineCount;
|
|
17754
|
+
if (stats[1]) stats[1].textContent = status.heartbeatCount;
|
|
17755
|
+
|
|
17756
|
+
// Update machine table
|
|
17757
|
+
const tbody = document.querySelector("tbody");
|
|
17758
|
+
if (tbody && status.machines) {
|
|
17759
|
+
tbody.innerHTML = status.machines
|
|
17760
|
+
.map((m) =>
|
|
17761
|
+
"<tr>" +
|
|
17762
|
+
"<td><code>" + m.machineId + "</code></td>" +
|
|
17763
|
+
"<td>" + (m.platform || "unknown") + "</td>" +
|
|
17764
|
+
'<td><span class="badge ' + m.heartbeatStatus + '">' + m.heartbeatStatus + '</span></td>' +
|
|
17765
|
+
"<td>" + (m.lastHeartbeatAt || "\\u2014") + "</td>" +
|
|
17766
|
+
"</tr>"
|
|
17767
|
+
)
|
|
17768
|
+
.join("");
|
|
17769
|
+
}
|
|
17770
|
+
|
|
17771
|
+
// Update doctor table
|
|
17772
|
+
const doctorTbody = document.getElementById("doctor-tbody");
|
|
17773
|
+
if (doctorTbody && doctor.checks) {
|
|
17774
|
+
doctorTbody.innerHTML = doctor.checks
|
|
17775
|
+
.map((c) =>
|
|
17776
|
+
"<tr>" +
|
|
17777
|
+
"<td>" + c.summary + "</td>" +
|
|
17778
|
+
'<td><span class="badge ' + c.status + '">' + c.status + '</span></td>' +
|
|
17779
|
+
'<td class="muted">' + c.detail + "</td>" +
|
|
17780
|
+
"</tr>"
|
|
17781
|
+
)
|
|
17782
|
+
.join("");
|
|
17783
|
+
}
|
|
17784
|
+
|
|
17785
|
+
// Update timestamp
|
|
17786
|
+
document.getElementById("last-updated").textContent =
|
|
17787
|
+
"updated " + new Date().toLocaleTimeString();
|
|
17788
|
+
} catch (e) {
|
|
17789
|
+
// Silently ignore fetch errors during page unload
|
|
17790
|
+
}
|
|
17791
|
+
}
|
|
17792
|
+
document.getElementById("last-updated").textContent =
|
|
17793
|
+
"updated " + new Date().toLocaleTimeString();
|
|
17794
|
+
setInterval(refreshData, REFRESH_INTERVAL);
|
|
17795
|
+
</script>
|
|
17362
17796
|
</body>
|
|
17363
17797
|
</html>`;
|
|
17364
17798
|
}
|
|
17799
|
+
async function parseJsonBody(request) {
|
|
17800
|
+
try {
|
|
17801
|
+
return await request.json();
|
|
17802
|
+
} catch {
|
|
17803
|
+
return {};
|
|
17804
|
+
}
|
|
17805
|
+
}
|
|
17806
|
+
function jsonError(message, status = 400) {
|
|
17807
|
+
return Response.json({ error: message }, { status });
|
|
17808
|
+
}
|
|
17365
17809
|
function startDashboardServer(options = {}) {
|
|
17366
17810
|
const info = getServeInfo(options);
|
|
17367
17811
|
return Bun.serve({
|
|
17368
17812
|
hostname: info.host,
|
|
17369
17813
|
port: info.port,
|
|
17370
|
-
fetch(request) {
|
|
17814
|
+
async fetch(request) {
|
|
17371
17815
|
const url = new URL(request.url);
|
|
17816
|
+
const machineId = url.searchParams.get("machine") || undefined;
|
|
17817
|
+
const tools = url.searchParams.get("tools")?.split(",").map((value) => value.trim()).filter(Boolean);
|
|
17372
17818
|
if (url.pathname === "/health") {
|
|
17373
17819
|
return Response.json({ ok: true, ...getServeInfo(options) });
|
|
17374
17820
|
}
|
|
@@ -17381,6 +17827,43 @@ function startDashboardServer(options = {}) {
|
|
|
17381
17827
|
if (url.pathname === "/api/notifications") {
|
|
17382
17828
|
return Response.json(listNotificationChannels());
|
|
17383
17829
|
}
|
|
17830
|
+
if (url.pathname === "/api/doctor") {
|
|
17831
|
+
return Response.json(runDoctor(machineId));
|
|
17832
|
+
}
|
|
17833
|
+
if (url.pathname === "/api/self-test") {
|
|
17834
|
+
return Response.json(runSelfTest());
|
|
17835
|
+
}
|
|
17836
|
+
if (url.pathname === "/api/apps/status") {
|
|
17837
|
+
return Response.json(getAppsStatus(machineId));
|
|
17838
|
+
}
|
|
17839
|
+
if (url.pathname === "/api/apps/diff") {
|
|
17840
|
+
return Response.json(diffApps(machineId));
|
|
17841
|
+
}
|
|
17842
|
+
if (url.pathname === "/api/install-claude/status") {
|
|
17843
|
+
return Response.json(getClaudeCliStatus(machineId, tools));
|
|
17844
|
+
}
|
|
17845
|
+
if (url.pathname === "/api/install-claude/diff") {
|
|
17846
|
+
return Response.json(diffClaudeCli(machineId, tools));
|
|
17847
|
+
}
|
|
17848
|
+
if (url.pathname === "/api/notifications/test") {
|
|
17849
|
+
if (request.method !== "POST") {
|
|
17850
|
+
return jsonError("Use POST for notification tests.", 405);
|
|
17851
|
+
}
|
|
17852
|
+
const body = await parseJsonBody(request);
|
|
17853
|
+
const channelId = typeof body["channelId"] === "string" ? body["channelId"] : undefined;
|
|
17854
|
+
if (!channelId) {
|
|
17855
|
+
return jsonError("channelId is required.");
|
|
17856
|
+
}
|
|
17857
|
+
const event = typeof body["event"] === "string" ? body["event"] : undefined;
|
|
17858
|
+
const message = typeof body["message"] === "string" ? body["message"] : undefined;
|
|
17859
|
+
const apply = body["apply"] === true;
|
|
17860
|
+
const yes = body["yes"] === true;
|
|
17861
|
+
try {
|
|
17862
|
+
return Response.json(await testNotificationChannel(channelId, event, message, { apply, yes }));
|
|
17863
|
+
} catch (error) {
|
|
17864
|
+
return jsonError(error instanceof Error ? error.message : String(error));
|
|
17865
|
+
}
|
|
17866
|
+
}
|
|
17384
17867
|
return new Response(renderDashboardHtml(), {
|
|
17385
17868
|
headers: {
|
|
17386
17869
|
"content-type": "text/html; charset=utf-8"
|
|
@@ -17390,11 +17873,279 @@ function startDashboardServer(options = {}) {
|
|
|
17390
17873
|
});
|
|
17391
17874
|
}
|
|
17392
17875
|
|
|
17876
|
+
// src/commands/self-test.ts
|
|
17877
|
+
function check(id, status, summary, detail) {
|
|
17878
|
+
return { id, status, summary, detail };
|
|
17879
|
+
}
|
|
17880
|
+
function runSelfTest() {
|
|
17881
|
+
const version = getPackageVersion();
|
|
17882
|
+
const status = getStatus();
|
|
17883
|
+
const doctor = runDoctor();
|
|
17884
|
+
const serveInfo = getServeInfo();
|
|
17885
|
+
const html = renderDashboardHtml();
|
|
17886
|
+
const notifications = listNotificationChannels();
|
|
17887
|
+
const apps = listApps(status.machineId);
|
|
17888
|
+
const appsDiff = diffApps(status.machineId);
|
|
17889
|
+
const cliPlan = buildClaudeInstallPlan(status.machineId);
|
|
17890
|
+
return {
|
|
17891
|
+
machineId: getLocalMachineId(),
|
|
17892
|
+
checks: [
|
|
17893
|
+
check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
|
|
17894
|
+
check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
|
|
17895
|
+
check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
|
|
17896
|
+
check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
|
|
17897
|
+
check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
|
|
17898
|
+
check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
|
|
17899
|
+
check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
|
|
17900
|
+
check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
|
|
17901
|
+
check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
|
|
17902
|
+
]
|
|
17903
|
+
};
|
|
17904
|
+
}
|
|
17905
|
+
|
|
17906
|
+
// src/commands/clipboard.ts
|
|
17907
|
+
import { createHash } from "crypto";
|
|
17908
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7, rmSync, writeFileSync as writeFileSync5 } from "fs";
|
|
17909
|
+
import { join as join9 } from "path";
|
|
17910
|
+
var DEFAULT_CONFIG = {
|
|
17911
|
+
version: 1,
|
|
17912
|
+
enabled: true,
|
|
17913
|
+
port: 19452,
|
|
17914
|
+
maxHistory: 500,
|
|
17915
|
+
maxSizeBytes: 10 * 1024 * 1024,
|
|
17916
|
+
skipPatterns: [
|
|
17917
|
+
"password",
|
|
17918
|
+
"secret",
|
|
17919
|
+
"token",
|
|
17920
|
+
"-----BEGIN",
|
|
17921
|
+
"AKIA"
|
|
17922
|
+
]
|
|
17923
|
+
};
|
|
17924
|
+
function resolveConfigPath(configPath) {
|
|
17925
|
+
if (configPath)
|
|
17926
|
+
return configPath;
|
|
17927
|
+
return join9(getDataDir(), "clipboard-config.json");
|
|
17928
|
+
}
|
|
17929
|
+
function resolveHistoryPath(historyPath) {
|
|
17930
|
+
if (historyPath)
|
|
17931
|
+
return historyPath;
|
|
17932
|
+
return getClipboardHistoryPath();
|
|
17933
|
+
}
|
|
17934
|
+
function getDefaultConfig() {
|
|
17935
|
+
return { ...DEFAULT_CONFIG, skipPatterns: [...DEFAULT_CONFIG.skipPatterns] };
|
|
17936
|
+
}
|
|
17937
|
+
function readConfig(configPath) {
|
|
17938
|
+
const path = resolveConfigPath(configPath);
|
|
17939
|
+
if (!existsSync8(path)) {
|
|
17940
|
+
return getDefaultConfig();
|
|
17941
|
+
}
|
|
17942
|
+
const parsed = JSON.parse(readFileSync7(path, "utf8"));
|
|
17943
|
+
return { ...getDefaultConfig(), ...parsed };
|
|
17944
|
+
}
|
|
17945
|
+
function writeConfig(config, configPath) {
|
|
17946
|
+
const path = resolveConfigPath(configPath);
|
|
17947
|
+
ensureParentDir(path);
|
|
17948
|
+
writeFileSync5(path, `${JSON.stringify(config, null, 2)}
|
|
17949
|
+
`, "utf8");
|
|
17950
|
+
}
|
|
17951
|
+
function readHistory(historyPath) {
|
|
17952
|
+
const path = resolveHistoryPath(historyPath);
|
|
17953
|
+
if (!existsSync8(path)) {
|
|
17954
|
+
return [];
|
|
17955
|
+
}
|
|
17956
|
+
try {
|
|
17957
|
+
return JSON.parse(readFileSync7(path, "utf8"));
|
|
17958
|
+
} catch {
|
|
17959
|
+
return [];
|
|
17960
|
+
}
|
|
17961
|
+
}
|
|
17962
|
+
function getOrCreateClipboardKey() {
|
|
17963
|
+
const keyPath = getClipboardKeyPath();
|
|
17964
|
+
if (existsSync8(keyPath)) {
|
|
17965
|
+
return readFileSync7(keyPath, "utf8").trim();
|
|
17966
|
+
}
|
|
17967
|
+
const key = createHash("sha256").update(crypto.randomUUID()).digest("hex").slice(0, 32);
|
|
17968
|
+
ensureParentDir(keyPath);
|
|
17969
|
+
writeFileSync5(keyPath, `${key}
|
|
17970
|
+
`, "utf8");
|
|
17971
|
+
return key;
|
|
17972
|
+
}
|
|
17973
|
+
function getDefaultClipboardConfig() {
|
|
17974
|
+
return getDefaultConfig();
|
|
17975
|
+
}
|
|
17976
|
+
function getConfigPath2(configPath) {
|
|
17977
|
+
return resolveConfigPath(configPath);
|
|
17978
|
+
}
|
|
17979
|
+
function readClipboardConfig(configPath) {
|
|
17980
|
+
return readConfig(configPath);
|
|
17981
|
+
}
|
|
17982
|
+
function writeClipboardConfig(config, configPath) {
|
|
17983
|
+
writeConfig(config, configPath);
|
|
17984
|
+
}
|
|
17985
|
+
function readClipboardHistory(historyPath) {
|
|
17986
|
+
return readHistory(historyPath);
|
|
17987
|
+
}
|
|
17988
|
+
function clearClipboardHistory(historyPath) {
|
|
17989
|
+
const path = resolveHistoryPath(historyPath);
|
|
17990
|
+
if (existsSync8(path)) {
|
|
17991
|
+
rmSync(path);
|
|
17992
|
+
}
|
|
17993
|
+
}
|
|
17994
|
+
function getClipboardStatus(historyPath) {
|
|
17995
|
+
const history = readHistory(historyPath);
|
|
17996
|
+
const config = readConfig();
|
|
17997
|
+
return {
|
|
17998
|
+
running: false,
|
|
17999
|
+
port: config.port,
|
|
18000
|
+
historyCount: history.length
|
|
18001
|
+
};
|
|
18002
|
+
}
|
|
18003
|
+
|
|
18004
|
+
// src/cli-utils.ts
|
|
18005
|
+
function parseIntegerOption(value, label, constraints = {}) {
|
|
18006
|
+
const parsed = Number.parseInt(value, 10);
|
|
18007
|
+
const { min, max } = constraints;
|
|
18008
|
+
if (!Number.isFinite(parsed) || !/^-?\d+$/.test(value.trim())) {
|
|
18009
|
+
throw new Error(`Invalid value for ${label}: ${value}`);
|
|
18010
|
+
}
|
|
18011
|
+
if (min !== undefined && parsed < min) {
|
|
18012
|
+
throw new Error(`Invalid value for ${label}: ${value}. Expected >= ${min}.`);
|
|
18013
|
+
}
|
|
18014
|
+
if (max !== undefined && parsed > max) {
|
|
18015
|
+
throw new Error(`Invalid value for ${label}: ${value}. Expected <= ${max}.`);
|
|
18016
|
+
}
|
|
18017
|
+
return parsed;
|
|
18018
|
+
}
|
|
18019
|
+
function renderKeyValueTable(entries) {
|
|
18020
|
+
const width = entries.reduce((max, [key]) => Math.max(max, key.length), 0);
|
|
18021
|
+
return entries.map(([key, value]) => `${key.padEnd(width)} ${value}`).join(`
|
|
18022
|
+
`);
|
|
18023
|
+
}
|
|
18024
|
+
function renderList(title, items) {
|
|
18025
|
+
if (items.length === 0) {
|
|
18026
|
+
return `${title}: none`;
|
|
18027
|
+
}
|
|
18028
|
+
return `${title}:
|
|
18029
|
+
${items.map((item) => `- ${item}`).join(`
|
|
18030
|
+
`)}`;
|
|
18031
|
+
}
|
|
18032
|
+
|
|
17393
18033
|
// src/cli/index.ts
|
|
18034
|
+
import { rmSync as rmSync2 } from "fs";
|
|
18035
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
17394
18036
|
var program2 = new Command;
|
|
17395
|
-
|
|
18037
|
+
function printJsonOrText(data, text, json = false) {
|
|
18038
|
+
if (json || program2.opts().quiet) {
|
|
18039
|
+
console.log(JSON.stringify(data, null, 2));
|
|
18040
|
+
return;
|
|
18041
|
+
}
|
|
18042
|
+
console.log(text);
|
|
18043
|
+
}
|
|
18044
|
+
function renderAppsListResult(result) {
|
|
18045
|
+
return [
|
|
18046
|
+
`machine: ${result.machineId}`,
|
|
18047
|
+
renderList("apps", result.apps.map((app) => `${app.name}${app.manager ? ` (${app.manager})` : ""}`))
|
|
18048
|
+
].join(`
|
|
18049
|
+
`);
|
|
18050
|
+
}
|
|
18051
|
+
function renderAppsStatusResult(result) {
|
|
18052
|
+
const lines = result.apps.map((app) => {
|
|
18053
|
+
const state = app.installed ? source_default.green("installed") : source_default.yellow("missing");
|
|
18054
|
+
return `${app.name.padEnd(18)} ${state} ${app.version ? `v${app.version}` : ""}`.trimEnd();
|
|
18055
|
+
});
|
|
18056
|
+
return [`machine: ${result.machineId} (${result.source})`, ...lines].join(`
|
|
18057
|
+
`);
|
|
18058
|
+
}
|
|
18059
|
+
function renderAppsDiffResult(result) {
|
|
18060
|
+
return [
|
|
18061
|
+
`machine: ${result.machineId} (${result.source})`,
|
|
18062
|
+
renderList("missing", result.missing),
|
|
18063
|
+
renderList("installed", result.installed)
|
|
18064
|
+
].join(`
|
|
18065
|
+
`);
|
|
18066
|
+
}
|
|
18067
|
+
function renderClaudeStatusResult(result) {
|
|
18068
|
+
const lines = result.tools.map((tool) => {
|
|
18069
|
+
const state = tool.installed ? source_default.green("installed") : source_default.yellow("missing");
|
|
18070
|
+
return `${tool.tool.padEnd(8)} ${state} ${tool.version || ""}`.trimEnd();
|
|
18071
|
+
});
|
|
18072
|
+
return [`machine: ${result.machineId} (${result.source})`, ...lines].join(`
|
|
18073
|
+
`);
|
|
18074
|
+
}
|
|
18075
|
+
function renderClaudeDiffResult(result) {
|
|
18076
|
+
return [
|
|
18077
|
+
`machine: ${result.machineId} (${result.source})`,
|
|
18078
|
+
renderList("missing", result.missing),
|
|
18079
|
+
renderList("installed", result.installed)
|
|
18080
|
+
].join(`
|
|
18081
|
+
`);
|
|
18082
|
+
}
|
|
18083
|
+
function renderNotificationConfigResult(config) {
|
|
18084
|
+
if (config.channels.length === 0) {
|
|
18085
|
+
return "notification channels: none";
|
|
18086
|
+
}
|
|
18087
|
+
return config.channels.map((channel) => `${channel.id} ${channel.enabled ? source_default.green("enabled") : source_default.yellow("disabled")} ${channel.type} -> ${channel.target}`).join(`
|
|
18088
|
+
`);
|
|
18089
|
+
}
|
|
18090
|
+
function renderNotificationTestResult(result) {
|
|
18091
|
+
return renderKeyValueTable([
|
|
18092
|
+
["channel", result.channelId],
|
|
18093
|
+
["mode", result.mode],
|
|
18094
|
+
["delivered", String(result.delivered)],
|
|
18095
|
+
["detail", result.detail],
|
|
18096
|
+
["preview", result.preview]
|
|
18097
|
+
]);
|
|
18098
|
+
}
|
|
18099
|
+
function renderNotificationDispatchResult(result) {
|
|
18100
|
+
return [
|
|
18101
|
+
`event: ${result.event}`,
|
|
18102
|
+
`message: ${result.message}`,
|
|
18103
|
+
...result.deliveries.map((delivery) => `${delivery.channelId} ${delivery.delivered ? source_default.green("delivered") : source_default.red("failed")} ${delivery.transport} ${delivery.detail}`)
|
|
18104
|
+
].join(`
|
|
18105
|
+
`);
|
|
18106
|
+
}
|
|
18107
|
+
function renderDoctorResult(report) {
|
|
18108
|
+
const header = `machine: ${report.machineId} (${report.source})`;
|
|
18109
|
+
const lines = report.checks.map((check2) => {
|
|
18110
|
+
const status = check2.status === "ok" ? source_default.green(check2.status) : check2.status === "warn" ? source_default.yellow(check2.status) : source_default.red(check2.status);
|
|
18111
|
+
return `${check2.id.padEnd(20)} ${status} ${check2.detail}`;
|
|
18112
|
+
});
|
|
18113
|
+
return [header, ...lines].join(`
|
|
18114
|
+
`);
|
|
18115
|
+
}
|
|
18116
|
+
function renderSelfTestResult(result) {
|
|
18117
|
+
return [
|
|
18118
|
+
`machine: ${result.machineId}`,
|
|
18119
|
+
...result.checks.map((check2) => {
|
|
18120
|
+
const status = check2.status === "ok" ? source_default.green(check2.status) : check2.status === "warn" ? source_default.yellow(check2.status) : source_default.red(check2.status);
|
|
18121
|
+
return `${check2.id.padEnd(20)} ${status} ${check2.detail}`;
|
|
18122
|
+
})
|
|
18123
|
+
].join(`
|
|
18124
|
+
`);
|
|
18125
|
+
}
|
|
18126
|
+
function renderFleetStatus(status) {
|
|
18127
|
+
return [
|
|
18128
|
+
renderKeyValueTable([
|
|
18129
|
+
["machine", status.machineId],
|
|
18130
|
+
["manifest", status.manifestPath],
|
|
18131
|
+
["db", status.dbPath],
|
|
18132
|
+
["notifications", status.notificationsPath],
|
|
18133
|
+
["manifest machines", String(status.manifestMachineCount)],
|
|
18134
|
+
["heartbeats", String(status.heartbeatCount)],
|
|
18135
|
+
["setup runs", String(status.recentSetupRuns)],
|
|
18136
|
+
["sync runs", String(status.recentSyncRuns)]
|
|
18137
|
+
]),
|
|
18138
|
+
"",
|
|
18139
|
+
...status.machines.map((machine) => `${machine.machineId.padEnd(18)} ${machine.platform || "unknown"} ${machine.heartbeatStatus} ${machine.lastHeartbeatAt || "\u2014"}`)
|
|
18140
|
+
].join(`
|
|
18141
|
+
`);
|
|
18142
|
+
}
|
|
18143
|
+
program2.name("machines").description("Machine fleet management CLI + MCP for developers").version(getPackageVersion()).option("-q, --quiet", "Suppress non-essential output");
|
|
17396
18144
|
var manifestCommand = program2.command("manifest").description("Manage the fleet manifest");
|
|
17397
18145
|
var appsCommand = program2.command("apps").description("Manage installed applications per machine");
|
|
18146
|
+
var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
|
|
18147
|
+
var clipboardCommand = program2.command("clipboard").description("Real-time clipboard sync across fleet machines");
|
|
18148
|
+
var installClaudeCommand = program2.command("install-claude").description("Install or inspect Claude, Codex, and Gemini CLIs");
|
|
17398
18149
|
manifestCommand.command("init").description("Create an empty fleet manifest").action(() => {
|
|
17399
18150
|
console.log(manifestInit());
|
|
17400
18151
|
});
|
|
@@ -17422,16 +18173,22 @@ manifestCommand.command("get").description("Print a single machine from the mani
|
|
|
17422
18173
|
manifestCommand.command("remove").description("Remove a machine from the manifest").argument("<id>", "Machine identifier").action((id) => {
|
|
17423
18174
|
console.log(JSON.stringify(manifestRemove(id), null, 2));
|
|
17424
18175
|
});
|
|
17425
|
-
manifestCommand.command("add").description("Add or replace a machine in the fleet manifest").
|
|
18176
|
+
manifestCommand.command("add").description("Add or replace a machine in the fleet manifest").option("--id <id>", "Machine identifier").option("--platform <platform>", "linux | macos | windows").option("--workspace-path <path>", "Primary workspace path").option("--hostname <hostname>", "Machine hostname").option("--ssh-address <sshAddress>", "Machine SSH address").option("--tailscale-name <tailscaleName>", "Machine Tailscale DNS name").option("--connection <connection>", "local | ssh | tailscale").option("--bun-path <path>", "Bun executable directory").option("--tag <tag...>", "Machine tags").option("--package <name...>", "Desired packages").option("--app <spec...>", "Desired apps as name[:manager[:packageName]]").option("--file <spec...>", "File sync spec source:target[:copy|symlink]").option("--metadata <json>", "Machine metadata as JSON").option("--from-stdin", "Read the full MachineManifest JSON from stdin").action((options) => {
|
|
18177
|
+
if (options["from-stdin"]) {
|
|
18178
|
+
if (process.stdin.isTTY) {
|
|
18179
|
+
console.error("error: --from-stdin requires piped input");
|
|
18180
|
+
process.exit(1);
|
|
18181
|
+
}
|
|
18182
|
+
const input = readFileSync8(0, "utf8");
|
|
18183
|
+
const machine2 = JSON.parse(input);
|
|
18184
|
+
console.log(JSON.stringify(manifestAdd(machine2), null, 2));
|
|
18185
|
+
return;
|
|
18186
|
+
}
|
|
17426
18187
|
const packages = Array.isArray(options["package"]) ? options["package"].map((name) => ({ name: String(name) })) : undefined;
|
|
17427
18188
|
const files = Array.isArray(options["file"]) ? options["file"].map((value) => {
|
|
17428
18189
|
const [source, target, mode] = String(value).split(":");
|
|
17429
18190
|
const normalizedMode = mode === "symlink" ? "symlink" : mode === "copy" ? "copy" : undefined;
|
|
17430
|
-
return {
|
|
17431
|
-
source,
|
|
17432
|
-
target,
|
|
17433
|
-
mode: normalizedMode
|
|
17434
|
-
};
|
|
18191
|
+
return { source, target, mode: normalizedMode };
|
|
17435
18192
|
}) : undefined;
|
|
17436
18193
|
const apps = Array.isArray(options["app"]) ? options["app"].map((value) => {
|
|
17437
18194
|
const [name, manager, packageName] = String(value).split(":");
|
|
@@ -17441,6 +18198,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
17441
18198
|
packageName
|
|
17442
18199
|
};
|
|
17443
18200
|
}) : undefined;
|
|
18201
|
+
const metadata = typeof options["metadata"] === "string" ? JSON.parse(options["metadata"]) : undefined;
|
|
17444
18202
|
const machine = {
|
|
17445
18203
|
id: String(options["id"]),
|
|
17446
18204
|
hostname: options["hostname"] ? String(options["hostname"]) : undefined,
|
|
@@ -17451,6 +18209,7 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
17451
18209
|
workspacePath: String(options["workspacePath"]),
|
|
17452
18210
|
bunPath: options["bunPath"] ? String(options["bunPath"]) : undefined,
|
|
17453
18211
|
tags: Array.isArray(options["tag"]) ? options["tag"].map(String) : undefined,
|
|
18212
|
+
metadata,
|
|
17454
18213
|
packages,
|
|
17455
18214
|
apps,
|
|
17456
18215
|
files
|
|
@@ -17459,65 +18218,55 @@ manifestCommand.command("add").description("Add or replace a machine in the flee
|
|
|
17459
18218
|
});
|
|
17460
18219
|
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
18220
|
const result = listApps(options.machine);
|
|
17462
|
-
|
|
17463
|
-
|
|
18221
|
+
printJsonOrText(result, renderAppsListResult(result), options.json);
|
|
18222
|
+
});
|
|
18223
|
+
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) => {
|
|
18224
|
+
const result = getAppsStatus(options.machine);
|
|
18225
|
+
printJsonOrText(result, renderAppsStatusResult(result), options.json);
|
|
18226
|
+
});
|
|
18227
|
+
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) => {
|
|
18228
|
+
const result = diffApps(options.machine);
|
|
18229
|
+
printJsonOrText(result, renderAppsDiffResult(result), options.json);
|
|
17464
18230
|
});
|
|
17465
|
-
appsCommand.command("plan").description("Preview app install steps for a machine").option("--machine <id>", "Machine identifier").
|
|
18231
|
+
appsCommand.command("plan").description("Preview app install steps for a machine").option("--machine <id>", "Machine identifier").action((options) => {
|
|
17466
18232
|
const result = buildAppsPlan(options.machine);
|
|
17467
|
-
|
|
17468
|
-
console.log(options.json ? rendered : rendered);
|
|
18233
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17469
18234
|
});
|
|
17470
|
-
appsCommand.command("apply").description("Install manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("--yes", "Confirm execution", false).
|
|
18235
|
+
appsCommand.command("apply").description("Install manifest-managed apps for a machine").option("--machine <id>", "Machine identifier").option("--yes", "Confirm execution", false).action((options) => {
|
|
17471
18236
|
const result = runAppsInstall(options.machine, { apply: true, yes: options.yes });
|
|
17472
|
-
|
|
17473
|
-
console.log(options.json ? rendered : rendered);
|
|
18237
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17474
18238
|
});
|
|
17475
18239
|
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
18240
|
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
18241
|
console.log(JSON.stringify(result, null, 2));
|
|
17482
18242
|
});
|
|
17483
18243
|
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
18244
|
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
18245
|
console.log(JSON.stringify(result, null, 2));
|
|
17490
18246
|
});
|
|
17491
18247
|
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
18248
|
const result = diffMachines(options.left, options.right);
|
|
17493
|
-
if (options.json) {
|
|
17494
|
-
console.log(JSON.stringify(result, null, 2));
|
|
17495
|
-
return;
|
|
17496
|
-
}
|
|
17497
18249
|
console.log(JSON.stringify(result, null, 2));
|
|
17498
18250
|
});
|
|
17499
18251
|
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
18252
|
const result = options.apply ? runBackup(options.bucket, options.prefix, { apply: true, yes: options.yes }) : buildBackupPlan(options.bucket, options.prefix);
|
|
17501
|
-
|
|
17502
|
-
console.log(options.json ? rendered : rendered);
|
|
18253
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17503
18254
|
});
|
|
17504
18255
|
var certCommand = program2.command("cert").description("Manage mkcert-based local SSL certificates");
|
|
17505
18256
|
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
18257
|
const result = options.apply ? runCertPlan(domains, { apply: true, yes: options.yes }) : buildCertPlan(domains);
|
|
17507
|
-
|
|
17508
|
-
console.log(options.json ? rendered : rendered);
|
|
18258
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17509
18259
|
});
|
|
17510
18260
|
var dnsCommand = program2.command("dns").description("Manage local domain mappings");
|
|
17511
|
-
var notificationsCommand = program2.command("notifications").description("Manage fleet alert delivery channels");
|
|
17512
18261
|
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,
|
|
17514
|
-
|
|
17515
|
-
console.log(options.json ? rendered : rendered);
|
|
18262
|
+
const result = addDomainMapping(options.domain, parseIntegerOption(options.port, "port", { min: 1, max: 65535 }), options.targetHost);
|
|
18263
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17516
18264
|
});
|
|
17517
|
-
dnsCommand.command("list").description("List saved local domain mappings").option("-j, --json", "Print JSON output", false).action((
|
|
17518
|
-
|
|
17519
|
-
|
|
17520
|
-
|
|
18265
|
+
dnsCommand.command("list").description("List saved local domain mappings").option("-j, --json", "Print JSON output", false).action(() => {
|
|
18266
|
+
console.log(JSON.stringify(listDomainMappings(), null, 2));
|
|
18267
|
+
});
|
|
18268
|
+
dnsCommand.command("render").description("Render hosts/proxy configuration for a domain").argument("<domain>", "Domain name").option("-j, --json", "Print JSON output", false).action((domain) => {
|
|
18269
|
+
console.log(JSON.stringify(renderDomainMapping(domain), null, 2));
|
|
17521
18270
|
});
|
|
17522
18271
|
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
18272
|
const result = addNotificationChannel({
|
|
@@ -17527,41 +18276,107 @@ notificationsCommand.command("add").description("Add or replace a notification c
|
|
|
17527
18276
|
events: options.event,
|
|
17528
18277
|
enabled: !options.disabled
|
|
17529
18278
|
});
|
|
17530
|
-
|
|
17531
|
-
console.log(options.json ? rendered : rendered);
|
|
18279
|
+
printJsonOrText(result, renderNotificationConfigResult(result), options.json);
|
|
17532
18280
|
});
|
|
17533
18281
|
notificationsCommand.command("list").description("List configured notification channels").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
17534
18282
|
const result = listNotificationChannels();
|
|
17535
|
-
|
|
17536
|
-
console.log(options.json ? rendered : rendered);
|
|
18283
|
+
printJsonOrText(result, renderNotificationConfigResult(result), options.json);
|
|
17537
18284
|
});
|
|
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, {
|
|
18285
|
+
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) => {
|
|
18286
|
+
const result = await testNotificationChannel(options.channel, options.event, options.message, {
|
|
17540
18287
|
apply: options.apply,
|
|
17541
18288
|
yes: options.yes
|
|
17542
18289
|
});
|
|
17543
|
-
|
|
17544
|
-
|
|
18290
|
+
printJsonOrText(result, renderNotificationTestResult(result), options.json);
|
|
18291
|
+
});
|
|
18292
|
+
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) => {
|
|
18293
|
+
const result = await dispatchNotificationEvent(options.event, options.message, { channelId: options.channel });
|
|
18294
|
+
printJsonOrText(result, renderNotificationDispatchResult(result), options.json);
|
|
17545
18295
|
});
|
|
17546
18296
|
notificationsCommand.command("remove").description("Remove a notification channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action((id, options) => {
|
|
17547
18297
|
const result = removeNotificationChannel(id);
|
|
17548
|
-
|
|
17549
|
-
|
|
18298
|
+
printJsonOrText(result, renderNotificationConfigResult(result), options.json);
|
|
18299
|
+
});
|
|
18300
|
+
clipboardCommand.command("init").description("Initialize clipboard sync (generate shared secret)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18301
|
+
const key = getOrCreateClipboardKey();
|
|
18302
|
+
const config = getDefaultClipboardConfig();
|
|
18303
|
+
writeClipboardConfig(config);
|
|
18304
|
+
const result = { keyPath: getClipboardKeyPath(), key, configPath: getConfigPath2(), config };
|
|
18305
|
+
printJsonOrText(result, `clipboard initialized
|
|
18306
|
+
key: ${key}
|
|
18307
|
+
port: ${config.port}`, options.json);
|
|
17550
18308
|
});
|
|
17551
|
-
|
|
17552
|
-
const
|
|
17553
|
-
const
|
|
17554
|
-
|
|
18309
|
+
clipboardCommand.command("status").description("Check clipboard sync status").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18310
|
+
const status = getClipboardStatus();
|
|
18311
|
+
const config = readClipboardConfig();
|
|
18312
|
+
const result = { ...status, enabled: config.enabled };
|
|
18313
|
+
printJsonOrText(result, `clipboard sync ${config.enabled ? source_default.green("enabled") : source_default.yellow("disabled")} (port ${status.port}, ${status.historyCount} entries)`, options.json);
|
|
17555
18314
|
});
|
|
17556
|
-
|
|
17557
|
-
|
|
17558
|
-
|
|
17559
|
-
|
|
18315
|
+
clipboardCommand.command("config").description("View or set clipboard sync config").option("--set <json>", "Set config values as JSON").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18316
|
+
if (options.set) {
|
|
18317
|
+
const partial = JSON.parse(options.set);
|
|
18318
|
+
const config2 = { ...readClipboardConfig(), ...partial };
|
|
18319
|
+
writeClipboardConfig(config2);
|
|
18320
|
+
}
|
|
18321
|
+
const config = readClipboardConfig();
|
|
18322
|
+
printJsonOrText(config, renderKeyValueTable([
|
|
18323
|
+
["enabled", String(config.enabled)],
|
|
18324
|
+
["port", String(config.port)],
|
|
18325
|
+
["maxHistory", String(config.maxHistory)],
|
|
18326
|
+
["maxSizeBytes", `${config.maxSizeBytes} bytes`],
|
|
18327
|
+
["skipPatterns", config.skipPatterns.join(", ")]
|
|
18328
|
+
]), options.json);
|
|
18329
|
+
});
|
|
18330
|
+
clipboardCommand.command("history").description("Show clipboard sync history").option("-j, --json", "Print JSON output", false).option("--limit <n>", "Show only the last N entries", "20").action((options) => {
|
|
18331
|
+
const limit = parseIntegerOption(options.limit, "limit", { min: 1, max: 100 });
|
|
18332
|
+
const entries = readClipboardHistory().slice(0, limit);
|
|
18333
|
+
if (options.json) {
|
|
18334
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
18335
|
+
return;
|
|
18336
|
+
}
|
|
18337
|
+
if (entries.length === 0) {
|
|
18338
|
+
console.log("clipboard history: empty");
|
|
18339
|
+
return;
|
|
18340
|
+
}
|
|
18341
|
+
for (const entry of entries) {
|
|
18342
|
+
const preview = entry.content.length > 80 ? `${entry.content.slice(0, 80)}...` : entry.content;
|
|
18343
|
+
console.log(`${source_default.dim(entry.timestamp)} ${entry.sourceMachine.padEnd(12)} ${entry.contentType.padEnd(5)} ${preview.replace(/\n/g, " ")}`);
|
|
18344
|
+
}
|
|
18345
|
+
});
|
|
18346
|
+
clipboardCommand.command("clear-history").description("Clear clipboard sync history").option("--yes", "Confirm without prompt", false).action((options) => {
|
|
18347
|
+
if (!options.yes) {
|
|
18348
|
+
console.error("error: this command requires --yes");
|
|
18349
|
+
process.exit(1);
|
|
18350
|
+
}
|
|
18351
|
+
clearClipboardHistory();
|
|
18352
|
+
console.log("clipboard history cleared");
|
|
18353
|
+
});
|
|
18354
|
+
clipboardCommand.command("key").description("Show or rotate the shared secret key").option("--rotate", "Generate a new key", false).option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18355
|
+
if (options.rotate) {
|
|
18356
|
+
rmSync2(getClipboardKeyPath(), { force: true });
|
|
18357
|
+
}
|
|
18358
|
+
const key = getOrCreateClipboardKey();
|
|
18359
|
+
printJsonOrText({ key }, key, options.json);
|
|
18360
|
+
});
|
|
18361
|
+
installClaudeCommand.command("status").description("Check installed state for Claude, Codex, and Gemini CLIs").option("--machine <id>", "Machine identifier").option("--tool <name...>", "CLI tools to inspect (claude, codex, gemini)").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18362
|
+
const result = getClaudeCliStatus(options.machine, options.tool);
|
|
18363
|
+
printJsonOrText(result, renderClaudeStatusResult(result), options.json);
|
|
18364
|
+
});
|
|
18365
|
+
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) => {
|
|
18366
|
+
const result = diffClaudeCli(options.machine, options.tool);
|
|
18367
|
+
printJsonOrText(result, renderClaudeDiffResult(result), options.json);
|
|
18368
|
+
});
|
|
18369
|
+
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) => {
|
|
18370
|
+
const result = buildClaudeInstallPlan(options.machine, options.tool);
|
|
18371
|
+
console.log(JSON.stringify(result, null, 2));
|
|
18372
|
+
});
|
|
18373
|
+
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) => {
|
|
18374
|
+
const result = runClaudeInstall(options.machine, options.tool, { apply: true, yes: options.yes });
|
|
18375
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17560
18376
|
});
|
|
17561
18377
|
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
18378
|
const result = options.apply ? runTailscaleInstall(options.machine, { apply: true, yes: options.yes }) : buildTailscaleInstallPlan(options.machine);
|
|
17563
|
-
|
|
17564
|
-
console.log(options.json ? rendered : rendered);
|
|
18379
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17565
18380
|
});
|
|
17566
18381
|
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
18382
|
if (options.json) {
|
|
@@ -17572,27 +18387,27 @@ program2.command("ssh").description("Choose the best SSH route for a machine").r
|
|
|
17572
18387
|
});
|
|
17573
18388
|
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
18389
|
const result = listPorts(options.machine);
|
|
17575
|
-
|
|
17576
|
-
console.log(options.json ? rendered : rendered);
|
|
18390
|
+
console.log(JSON.stringify(result, null, 2));
|
|
17577
18391
|
});
|
|
17578
18392
|
program2.command("status").description("Print local machine and storage status").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
17579
18393
|
const status = getStatus();
|
|
17580
|
-
|
|
17581
|
-
|
|
18394
|
+
printJsonOrText(status, renderFleetStatus(status), options.json);
|
|
18395
|
+
});
|
|
18396
|
+
program2.command("doctor").description("Run machine preflight checks").option("--machine <id>", "Machine identifier").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18397
|
+
const result = runDoctor(options.machine);
|
|
18398
|
+
printJsonOrText(result, renderDoctorResult(result), options.json);
|
|
18399
|
+
});
|
|
18400
|
+
program2.command("self-test").description("Run local package smoke checks").option("-j, --json", "Print JSON output", false).action((options) => {
|
|
18401
|
+
const result = runSelfTest();
|
|
18402
|
+
printJsonOrText(result, renderSelfTestResult(result), options.json);
|
|
17582
18403
|
});
|
|
17583
18404
|
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
|
-
});
|
|
18405
|
+
const info = getServeInfo({ host: options.host, port: parseIntegerOption(options.port, "port", { min: 1, max: 65535 }) });
|
|
17588
18406
|
if (options.json) {
|
|
17589
18407
|
console.log(JSON.stringify(info, null, 2));
|
|
17590
18408
|
return;
|
|
17591
18409
|
}
|
|
17592
|
-
const server = startDashboardServer({
|
|
17593
|
-
host: info.host,
|
|
17594
|
-
port: info.port
|
|
17595
|
-
});
|
|
18410
|
+
const server = startDashboardServer({ host: info.host, port: info.port });
|
|
17596
18411
|
console.log(source_default.green(`machines dashboard listening on http://${server.hostname}:${server.port}`));
|
|
17597
18412
|
});
|
|
17598
18413
|
await program2.parseAsync(process.argv);
|