@hasna/machines 0.0.36 → 0.0.38

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/mcp/index.js CHANGED
@@ -4160,6 +4160,9 @@ function ensureParentDir(filePath) {
4160
4160
 
4161
4161
  // src/redaction.ts
4162
4162
  var REDACTED_VALUE = "[redacted]";
4163
+ var PRIVATE_OUTPUT_ENV = "HASNA_MACHINES_ALLOW_PRIVATE_OUTPUT";
4164
+ var PRIVATE_OUTPUT_FALLBACK_ENV = "MACHINES_ALLOW_PRIVATE_OUTPUT";
4165
+ var PRIVATE_OUTPUT_DENIED_WARNING = `private_output_denied:set ${PRIVATE_OUTPUT_ENV}=1 to allow private metadata output`;
4163
4166
  var SENSITIVE_KEY_PATTERN = /(password|passwd|token|credential|private[_-]?key|privateKey|api[_-]?key|github.*key|pem|secret)/i;
4164
4167
  var SECRET_REFERENCE_KEY_PATTERN = /(secret(ref(erence)?|key)?|secretRef|secretKey)$/i;
4165
4168
  var SENSITIVE_VALUE_PATTERNS = [
@@ -4170,9 +4173,17 @@ var SENSITIVE_VALUE_PATTERNS = [
4170
4173
  /\bAKIA[0-9A-Z]{16}\b/,
4171
4174
  /\bsk-[A-Za-z0-9_-]{20,}\b/
4172
4175
  ];
4176
+ var IPV4_PATTERN = /\b(?:10|127|169\.254|172\.(?:1[6-9]|2\d|3[0-1])|192\.168|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7]))(?:\.\d{1,3}){2}\b/g;
4177
+ var IPV6_PATTERN = /\b(?:fc|fd|fe80)[0-9a-f:]*:[0-9a-f:]+\b/gi;
4178
+ var DATABASE_URL_PATTERN = /\b(?:postgres(?:ql)?|mysql|mariadb|redis|mongodb|s3):\/\/[^\s"'<>]+/gi;
4179
+ var PRIVATE_HOST_PATTERN = /\b(?:[A-Za-z0-9._%+-]+@)?[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*(?:\.tailnet(?:\.[A-Za-z0-9-]+)*|\.ts\.net|\.private(?:\.[A-Za-z0-9-]+)*|\.internal|\.local)\b/gi;
4173
4180
  function isSensitiveKey(key) {
4174
4181
  return SENSITIVE_KEY_PATTERN.test(key);
4175
4182
  }
4183
+ function isPrivateOutputEnabled(env = process.env) {
4184
+ const value = env[PRIVATE_OUTPUT_ENV] ?? env[PRIVATE_OUTPUT_FALLBACK_ENV];
4185
+ return ["1", "true", "yes", "on", "private"].includes(String(value ?? "").trim().toLowerCase());
4186
+ }
4176
4187
  function isSecretReferenceKey(key) {
4177
4188
  return SECRET_REFERENCE_KEY_PATTERN.test(key);
4178
4189
  }
@@ -4185,6 +4196,12 @@ function isRecord(value) {
4185
4196
  function redactPath(value) {
4186
4197
  return value.replace(/\/home\/[^/\s]+/g, "/home/<user>").replace(/\/Users\/[^/\s]+/g, "/Users/<user>").replace(/[A-Za-z]:\\Users\\[^\\\s]+/g, "C:\\Users\\<user>");
4187
4198
  }
4199
+ function redactErrorMessage(value) {
4200
+ return redactPath(value).replace(DATABASE_URL_PATTERN, (match) => {
4201
+ const scheme = match.match(/^([a-z][a-z0-9+.-]*:\/\/)/i)?.[1] ?? "";
4202
+ return `${scheme}${REDACTED_VALUE}`;
4203
+ }).replace(IPV4_PATTERN, REDACTED_VALUE).replace(IPV6_PATTERN, REDACTED_VALUE).replace(PRIVATE_HOST_PATTERN, REDACTED_VALUE);
4204
+ }
4188
4205
  function redactPrivateRef(value) {
4189
4206
  const trimmed = value.trim();
4190
4207
  const scheme = trimmed.match(/^([a-z][a-z0-9+.-]*:\/\/)/i);
@@ -4459,6 +4476,21 @@ class SqliteAdapter {
4459
4476
  }
4460
4477
  }
4461
4478
  var adapter = null;
4479
+ var AGENT_HEARTBEAT_COLUMNS = [
4480
+ { name: "daemon_version", definition: "TEXT" },
4481
+ { name: "agent_mode", definition: "TEXT" },
4482
+ { name: "platform", definition: "TEXT" },
4483
+ { name: "os_version", definition: "TEXT" },
4484
+ { name: "os_build", definition: "TEXT" },
4485
+ { name: "arch", definition: "TEXT" },
4486
+ { name: "uptime_seconds", definition: "INTEGER" },
4487
+ { name: "tool_versions_json", definition: "TEXT" },
4488
+ { name: "tailscale_json", definition: "TEXT" },
4489
+ { name: "storage_sync_status", definition: "TEXT" },
4490
+ { name: "storage_sync_last_error", definition: "TEXT" },
4491
+ { name: "doctor_summary_json", definition: "TEXT" },
4492
+ { name: "private_metadata", definition: "INTEGER NOT NULL DEFAULT 0" }
4493
+ ];
4462
4494
  function createTables(db) {
4463
4495
  db.exec(`
4464
4496
  CREATE TABLE IF NOT EXISTS agent_heartbeats (
@@ -4466,9 +4498,23 @@ function createTables(db) {
4466
4498
  pid INTEGER NOT NULL,
4467
4499
  status TEXT NOT NULL,
4468
4500
  updated_at TEXT NOT NULL,
4501
+ daemon_version TEXT,
4502
+ agent_mode TEXT,
4503
+ platform TEXT,
4504
+ os_version TEXT,
4505
+ os_build TEXT,
4506
+ arch TEXT,
4507
+ uptime_seconds INTEGER,
4508
+ tool_versions_json TEXT,
4509
+ tailscale_json TEXT,
4510
+ storage_sync_status TEXT,
4511
+ storage_sync_last_error TEXT,
4512
+ doctor_summary_json TEXT,
4513
+ private_metadata INTEGER NOT NULL DEFAULT 0,
4469
4514
  PRIMARY KEY (machine_id, pid)
4470
4515
  )
4471
4516
  `);
4517
+ migrateAgentHeartbeats(db);
4472
4518
  db.exec(`
4473
4519
  CREATE TABLE IF NOT EXISTS setup_runs (
4474
4520
  id TEXT PRIMARY KEY,
@@ -4490,6 +4536,15 @@ function createTables(db) {
4490
4536
  )
4491
4537
  `);
4492
4538
  }
4539
+ function migrateAgentHeartbeats(db) {
4540
+ const columns = db.query("PRAGMA table_info(agent_heartbeats)").all();
4541
+ const existing = new Set(columns.map((column) => column.name));
4542
+ for (const column of AGENT_HEARTBEAT_COLUMNS) {
4543
+ if (existing.has(column.name))
4544
+ continue;
4545
+ db.exec(`ALTER TABLE agent_heartbeats ADD COLUMN ${column.name} ${column.definition}`);
4546
+ }
4547
+ }
4493
4548
  function getAdapter(path = getDbPath()) {
4494
4549
  if (path === ":memory:") {
4495
4550
  const memoryAdapter = new SqliteAdapter(path);
@@ -4518,12 +4573,12 @@ function getLocalMachineId() {
4518
4573
  function listHeartbeats(machineId) {
4519
4574
  const db = getDb();
4520
4575
  if (machineId) {
4521
- return db.query(`SELECT machine_id, pid, status, updated_at
4576
+ return db.query(`SELECT *
4522
4577
  FROM agent_heartbeats
4523
4578
  WHERE machine_id = ?
4524
4579
  ORDER BY updated_at DESC`).all(machineId);
4525
4580
  }
4526
- return db.query(`SELECT machine_id, pid, status, updated_at
4581
+ return db.query(`SELECT *
4527
4582
  FROM agent_heartbeats
4528
4583
  ORDER BY updated_at DESC`).all();
4529
4584
  }
@@ -4710,6 +4765,16 @@ function routeRank(hint) {
4710
4765
  function selectRouteHint(hints) {
4711
4766
  return [...hints].sort((left, right) => routeRank(left) - routeRank(right))[0] ?? null;
4712
4767
  }
4768
+ function parseHeartbeatJson(value) {
4769
+ if (!value)
4770
+ return null;
4771
+ try {
4772
+ const parsed = JSON.parse(value);
4773
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
4774
+ } catch {
4775
+ return null;
4776
+ }
4777
+ }
4713
4778
  function buildEntry(input) {
4714
4779
  const manifest = input.manifest;
4715
4780
  const peer = input.peer;
@@ -4732,6 +4797,22 @@ function buildEntry(input) {
4732
4797
  manifest_declared: Boolean(manifest),
4733
4798
  heartbeat_status: input.heartbeat?.status ?? "unknown",
4734
4799
  last_heartbeat_at: input.heartbeat?.updated_at ?? null,
4800
+ agent: {
4801
+ pid: input.heartbeat?.pid ?? null,
4802
+ daemon_version: input.heartbeat?.daemon_version ?? null,
4803
+ mode: input.heartbeat?.agent_mode ?? null,
4804
+ private_metadata: Boolean(input.heartbeat?.private_metadata),
4805
+ platform: input.heartbeat?.platform ?? null,
4806
+ os_version: input.heartbeat?.os_version ?? null,
4807
+ os_build: input.heartbeat?.os_build ?? null,
4808
+ arch: input.heartbeat?.arch ?? null,
4809
+ uptime_seconds: input.heartbeat?.uptime_seconds ?? null,
4810
+ tool_versions: parseHeartbeatJson(input.heartbeat?.tool_versions_json),
4811
+ tailscale: parseHeartbeatJson(input.heartbeat?.tailscale_json),
4812
+ storage_sync_status: input.heartbeat?.storage_sync_status ?? null,
4813
+ storage_sync_last_error: input.heartbeat?.storage_sync_last_error ?? null,
4814
+ doctor_summary: parseHeartbeatJson(input.heartbeat?.doctor_summary_json)
4815
+ },
4735
4816
  tailscale: {
4736
4817
  dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
4737
4818
  ips: peer?.TailscaleIPs ?? [],
@@ -4791,6 +4872,72 @@ function discoverMachineTopology(options = {}) {
4791
4872
  warnings
4792
4873
  };
4793
4874
  }
4875
+ function redactFleetString(value) {
4876
+ if (!value)
4877
+ return value;
4878
+ return redactErrorMessage(value);
4879
+ }
4880
+ function redactPublicRecord(value) {
4881
+ if (!value)
4882
+ return null;
4883
+ const redacted = {};
4884
+ for (const [key, entry] of Object.entries(value)) {
4885
+ if (/(host|hostname|dns|ip|ips|user|username|serial|address|target|url|token|secret|password|credential)/i.test(key)) {
4886
+ redacted[key] = REDACTED_VALUE;
4887
+ continue;
4888
+ }
4889
+ if (typeof entry === "string") {
4890
+ redacted[key] = redactFleetString(entry);
4891
+ } else if (Array.isArray(entry)) {
4892
+ redacted[key] = entry.map((item) => {
4893
+ if (typeof item === "string")
4894
+ return redactFleetString(item);
4895
+ if (item && typeof item === "object")
4896
+ return redactPublicRecord(item);
4897
+ return item;
4898
+ });
4899
+ } else if (entry && typeof entry === "object") {
4900
+ redacted[key] = redactPublicRecord(entry);
4901
+ } else {
4902
+ redacted[key] = entry;
4903
+ }
4904
+ }
4905
+ return redactSensitiveValue(redacted);
4906
+ }
4907
+ function redactTopologyForOutput(topology, options = {}) {
4908
+ if (options.privateMetadata)
4909
+ return topology;
4910
+ return {
4911
+ ...topology,
4912
+ local_hostname: REDACTED_VALUE,
4913
+ warnings: topology.warnings.map(redactFleetString),
4914
+ machines: topology.machines.map((machine) => ({
4915
+ ...machine,
4916
+ hostname: machine.hostname ? REDACTED_VALUE : null,
4917
+ user: machine.user ? REDACTED_VALUE : null,
4918
+ tailscale: {
4919
+ ...machine.tailscale,
4920
+ dns_name: machine.tailscale.dns_name ? REDACTED_VALUE : null,
4921
+ ips: machine.tailscale.ips.map(() => REDACTED_VALUE)
4922
+ },
4923
+ ssh: {
4924
+ ...machine.ssh,
4925
+ address: machine.ssh.address ? REDACTED_VALUE : null,
4926
+ command_target: machine.ssh.command_target ? REDACTED_VALUE : null
4927
+ },
4928
+ route_hints: machine.route_hints.map((hint) => ({
4929
+ ...hint,
4930
+ target: REDACTED_VALUE
4931
+ })),
4932
+ agent: {
4933
+ ...machine.agent,
4934
+ tailscale: redactPublicRecord(machine.agent.tailscale),
4935
+ storage_sync_last_error: machine.agent.storage_sync_last_error ? redactFleetString(machine.agent.storage_sync_last_error) : null,
4936
+ doctor_summary: redactPublicRecord(machine.agent.doctor_summary)
4937
+ }
4938
+ }))
4939
+ };
4940
+ }
4794
4941
  function normalizeMachineAlias(value) {
4795
4942
  return value.trim().replace(/\.$/, "").toLowerCase();
4796
4943
  }
@@ -4984,6 +5131,20 @@ function resolveMachineRoute(machineId, options = {}) {
4984
5131
  warnings
4985
5132
  };
4986
5133
  }
5134
+ function redactRouteForOutput(route, options = {}) {
5135
+ if (options.privateMetadata)
5136
+ return route;
5137
+ return {
5138
+ ...route,
5139
+ target: route.target ? REDACTED_VALUE : null,
5140
+ command_target: route.command_target ? REDACTED_VALUE : null,
5141
+ warnings: route.warnings.map(redactFleetString),
5142
+ evidence: {
5143
+ ...route.evidence,
5144
+ selected_hint: route.evidence.selected_hint ? { ...route.evidence.selected_hint, target: REDACTED_VALUE } : null
5145
+ }
5146
+ };
5147
+ }
4987
5148
  function isRecord2(value) {
4988
5149
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
4989
5150
  }
@@ -5783,6 +5944,298 @@ function diffMachines(leftMachineId, rightMachineId) {
5783
5944
  };
5784
5945
  }
5785
5946
 
5947
+ // src/commands/daemon.ts
5948
+ import { platform as osPlatform } from "os";
5949
+ var DEFAULT_SERVICE_NAME = "machines-agent";
5950
+ var DEFAULT_EXECUTABLE = "/usr/local/bin/machines-agent";
5951
+ var DEFAULT_INTERVAL_MS = 30000;
5952
+ var ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
5953
+ var SERVICE_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
5954
+ function buildDaemonServicePlan(options) {
5955
+ const resolved = resolveDaemonServiceOptions(options);
5956
+ const files = resolved.action === "install" ? [buildServiceFile(resolved)] : [];
5957
+ return {
5958
+ platform: resolved.platform,
5959
+ mode: resolved.mode,
5960
+ action: resolved.action,
5961
+ serviceName: resolved.serviceName,
5962
+ serviceId: resolved.serviceId,
5963
+ executable: resolved.executable,
5964
+ intervalMs: resolved.intervalMs,
5965
+ commands: buildActionCommands(resolved),
5966
+ files,
5967
+ warnings: resolved.warnings,
5968
+ manualSteps: buildManualSteps(resolved, files)
5969
+ };
5970
+ }
5971
+ function resolveDaemonServiceOptions(options) {
5972
+ const warnings = [];
5973
+ const serviceName = normalizeServiceName(options.serviceName, warnings);
5974
+ const platform4 = normalizePlatform3(options.platform, warnings);
5975
+ const mode = options.mode ?? "user";
5976
+ const intervalMs = normalizeIntervalMs(options.intervalMs, warnings);
5977
+ const executable = options.executable?.trim() || DEFAULT_EXECUTABLE;
5978
+ if (platform4 === "linux" && !executable.startsWith("/")) {
5979
+ warnings.push("systemd units should use an absolute executable path; install plan keeps the provided path unchanged.");
5980
+ }
5981
+ return {
5982
+ action: options.action,
5983
+ platform: platform4,
5984
+ mode,
5985
+ serviceName,
5986
+ serviceId: serviceName,
5987
+ executable,
5988
+ intervalMs,
5989
+ env: buildEnvironment(serviceName, options, warnings),
5990
+ warnings
5991
+ };
5992
+ }
5993
+ function normalizeServiceName(value, warnings) {
5994
+ const serviceName = value?.trim() || DEFAULT_SERVICE_NAME;
5995
+ if (SERVICE_NAME_PATTERN.test(serviceName))
5996
+ return serviceName;
5997
+ warnings.push(`Invalid serviceName "${serviceName}"; using ${DEFAULT_SERVICE_NAME}.`);
5998
+ return DEFAULT_SERVICE_NAME;
5999
+ }
6000
+ function normalizePlatform3(value, warnings) {
6001
+ const raw = value ?? osPlatform();
6002
+ if (raw === "darwin" || raw === "macos")
6003
+ return "macos";
6004
+ if (raw === "linux")
6005
+ return "linux";
6006
+ warnings.push(`Unsupported platform "${raw}"; using linux service planning.`);
6007
+ return "linux";
6008
+ }
6009
+ function normalizeIntervalMs(value, warnings) {
6010
+ if (value === undefined)
6011
+ return DEFAULT_INTERVAL_MS;
6012
+ if (Number.isInteger(value) && value > 0)
6013
+ return value;
6014
+ warnings.push(`Invalid intervalMs "${String(value)}"; using ${DEFAULT_INTERVAL_MS}.`);
6015
+ return DEFAULT_INTERVAL_MS;
6016
+ }
6017
+ function buildEnvironment(serviceName, options, warnings) {
6018
+ const env = {
6019
+ HASNA_MACHINES_AGENT_MODE: "daemon",
6020
+ HASNA_MACHINES_AGENT_SERVICE: serviceName
6021
+ };
6022
+ if (options.storagePush) {
6023
+ env["HASNA_MACHINES_AGENT_STORAGE_PUSH"] = "1";
6024
+ env["HASNA_MACHINES_AGENT_STORAGE_PUSH_BACKOFF_MS"] = "250";
6025
+ env["HASNA_MACHINES_AGENT_STORAGE_PUSH_RETRIES"] = "2";
6026
+ env["HASNA_MACHINES_STORAGE_MODE"] = "hybrid";
6027
+ env["HASNA_MACHINES_DATABASE_URL"] = placeholderForEnv("HASNA_MACHINES_DATABASE_URL");
6028
+ warnings.push("storagePush is represented with env placeholders; no database URL is embedded in the plan.");
6029
+ }
6030
+ if (options.doctorSummary) {
6031
+ env["HASNA_MACHINES_AGENT_DOCTOR_SUMMARY"] = "1";
6032
+ }
6033
+ if (options.privateMetadata === true) {
6034
+ env["HASNA_MACHINES_PRIVATE_METADATA"] = "1";
6035
+ warnings.push("privateMetadata=true enables private host/network facts in heartbeat rows; do not share private-mode output publicly.");
6036
+ } else if (Array.isArray(options.privateMetadata)) {
6037
+ addEnvPlaceholders(env, options.privateMetadata, warnings);
6038
+ }
6039
+ addEnvPlaceholders(env, options.env ?? [], warnings);
6040
+ return Object.fromEntries(Object.entries(env).sort(([left], [right]) => left.localeCompare(right)));
6041
+ }
6042
+ function addEnvPlaceholders(env, names, warnings) {
6043
+ for (const rawName of names) {
6044
+ const name = rawName.trim();
6045
+ if (!ENV_NAME_PATTERN.test(name)) {
6046
+ warnings.push(`Invalid environment variable name "${rawName}"; skipped.`);
6047
+ continue;
6048
+ }
6049
+ env[name] = placeholderForEnv(name);
6050
+ }
6051
+ }
6052
+ function placeholderForEnv(name) {
6053
+ return `<set:${name}>`;
6054
+ }
6055
+ function buildServiceFile(options) {
6056
+ if (options.platform === "macos") {
6057
+ return {
6058
+ id: "launchd-plist",
6059
+ description: "launchd property list for machines-agent",
6060
+ path: launchdPlistPath(options),
6061
+ mode: "0644",
6062
+ content: launchdPlist(options)
6063
+ };
6064
+ }
6065
+ return {
6066
+ id: "systemd-unit",
6067
+ description: "systemd unit for machines-agent",
6068
+ path: systemdUnitPath(options),
6069
+ mode: "0644",
6070
+ content: systemdUnit(options)
6071
+ };
6072
+ }
6073
+ function buildActionCommands(options) {
6074
+ if (options.platform === "macos")
6075
+ return buildLaunchdCommands(options);
6076
+ return buildSystemdCommands(options);
6077
+ }
6078
+ function buildLaunchdCommands(options) {
6079
+ const domain = launchdDomain(options);
6080
+ const serviceTarget = `${domain}/${options.serviceId}`;
6081
+ const plistPath = launchdPlistPath(options);
6082
+ const sudo = options.mode === "system";
6083
+ if (options.action === "install") {
6084
+ return [
6085
+ command("launchd-bootout-existing", "Unload any existing launchd job before bootstrap.", "launchctl", ["bootout", domain, plistPath], sudo, true, true),
6086
+ command("launchd-bootstrap", "Load the planned launchd plist.", "launchctl", ["bootstrap", domain, plistPath], sudo, true),
6087
+ command("launchd-enable", "Enable the launchd service.", "launchctl", ["enable", serviceTarget], sudo, true),
6088
+ command("launchd-kickstart", "Start or restart the launchd service.", "launchctl", ["kickstart", "-k", serviceTarget], sudo, true)
6089
+ ];
6090
+ }
6091
+ if (options.action === "uninstall") {
6092
+ return [
6093
+ command("launchd-bootout", "Unload the launchd job.", "launchctl", ["bootout", domain, plistPath], sudo, true),
6094
+ command("remove-launchd-plist", "Remove the planned launchd plist file.", "rm", ["-f", plistPath], sudo, true)
6095
+ ];
6096
+ }
6097
+ if (options.action === "restart") {
6098
+ return [command("launchd-kickstart", "Restart the launchd service.", "launchctl", ["kickstart", "-k", serviceTarget], sudo, true)];
6099
+ }
6100
+ if (options.action === "status") {
6101
+ return [command("launchd-print", "Print launchd service status.", "launchctl", ["print", serviceTarget], sudo, false)];
6102
+ }
6103
+ return [
6104
+ command("launchd-logs", "Stream logs for machines-agent.", "log", ["stream", "--style", "compact", "--predicate", `process == "${basename(options.executable)}" OR eventMessage CONTAINS "${options.serviceId}"`], false, false)
6105
+ ];
6106
+ }
6107
+ function buildSystemdCommands(options) {
6108
+ const userFlag = options.mode === "user" ? ["--user"] : [];
6109
+ const sudo = options.mode === "system";
6110
+ const unitName = systemdUnitName(options);
6111
+ const daemonReload = command("systemd-daemon-reload", "Reload systemd unit metadata.", "systemctl", [...userFlag, "daemon-reload"], sudo, true);
6112
+ if (options.action === "install") {
6113
+ return [
6114
+ daemonReload,
6115
+ command("systemd-enable-now", "Enable and start the systemd service.", "systemctl", [...userFlag, "enable", "--now", unitName], sudo, true)
6116
+ ];
6117
+ }
6118
+ if (options.action === "uninstall") {
6119
+ return [
6120
+ command("systemd-disable-now", "Stop and disable the systemd service.", "systemctl", [...userFlag, "disable", "--now", unitName], sudo, true),
6121
+ command("remove-systemd-unit", "Remove the planned systemd unit file.", "rm", ["-f", systemdUnitPath(options)], sudo, true),
6122
+ daemonReload
6123
+ ];
6124
+ }
6125
+ if (options.action === "restart") {
6126
+ return [command("systemd-restart", "Restart the systemd service.", "systemctl", [...userFlag, "restart", unitName], sudo, true)];
6127
+ }
6128
+ if (options.action === "status") {
6129
+ return [command("systemd-status", "Show systemd service status.", "systemctl", [...userFlag, "status", unitName, "--no-pager"], sudo, false)];
6130
+ }
6131
+ return [
6132
+ command("systemd-logs", "Follow journal logs for the service.", "journalctl", [...userFlag, "-u", unitName, "-f", "--no-pager"], sudo, false)
6133
+ ];
6134
+ }
6135
+ function buildManualSteps(options, files) {
6136
+ const steps = [];
6137
+ if (files[0])
6138
+ steps.push(`Write ${files[0].id} content to ${files[0].path} with mode ${files[0].mode}.`);
6139
+ if (options.mode === "system")
6140
+ steps.push("Run commands marked sudo with root privileges.");
6141
+ if (options.platform === "linux" && options.mode === "user") {
6142
+ steps.push("Run commands as the target user; enable lingering separately if the service must survive logout.");
6143
+ }
6144
+ if (options.action === "logs")
6145
+ steps.push("Stop the log command manually when finished.");
6146
+ return steps;
6147
+ }
6148
+ function command(id, description, program, args, sudo, mutates, allowFailure = false) {
6149
+ return { id, description, program, args, sudo, mutates, ...allowFailure ? { allowFailure: true } : {} };
6150
+ }
6151
+ function launchdPlist(options) {
6152
+ const env = Object.entries(options.env).map(([name, value]) => ` <key>${xmlEscape(name)}</key>
6153
+ <string>${xmlEscape(value)}</string>`).join(`
6154
+ `);
6155
+ return `<?xml version="1.0" encoding="UTF-8"?>
6156
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
6157
+ <plist version="1.0">
6158
+ <dict>
6159
+ <key>Label</key>
6160
+ <string>${xmlEscape(options.serviceId)}</string>
6161
+ <key>ProgramArguments</key>
6162
+ <array>
6163
+ <string>${xmlEscape(options.executable)}</string>
6164
+ <string>--interval-ms</string>
6165
+ <string>${options.intervalMs}</string>
6166
+ </array>
6167
+ <key>EnvironmentVariables</key>
6168
+ <dict>
6169
+ ${env}
6170
+ </dict>
6171
+ <key>KeepAlive</key>
6172
+ <true/>
6173
+ <key>RunAtLoad</key>
6174
+ <true/>
6175
+ <key>StandardOutPath</key>
6176
+ <string>${xmlEscape(launchdLogPath(options, "out"))}</string>
6177
+ <key>StandardErrorPath</key>
6178
+ <string>${xmlEscape(launchdLogPath(options, "err"))}</string>
6179
+ </dict>
6180
+ </plist>
6181
+ `;
6182
+ }
6183
+ function systemdUnit(options) {
6184
+ const env = Object.entries(options.env).map(([name, value]) => `Environment=${quoteSystemdEnvironment(name, value)}`).join(`
6185
+ `);
6186
+ return `[Unit]
6187
+ Description=Hasna machines agent
6188
+ After=network-online.target
6189
+ Wants=network-online.target
6190
+
6191
+ [Service]
6192
+ Type=simple
6193
+ ExecStart=${quoteSystemdExecArg(options.executable)} --interval-ms ${options.intervalMs}
6194
+ Restart=always
6195
+ RestartSec=10
6196
+ ${env}
6197
+
6198
+ [Install]
6199
+ WantedBy=${options.mode === "system" ? "multi-user.target" : "default.target"}
6200
+ `;
6201
+ }
6202
+ function launchdDomain(options) {
6203
+ return options.mode === "system" ? "system" : "gui/$UID";
6204
+ }
6205
+ function launchdPlistPath(options) {
6206
+ if (options.mode === "system")
6207
+ return `/Library/LaunchDaemons/${options.serviceId}.plist`;
6208
+ return `$HOME/Library/LaunchAgents/${options.serviceId}.plist`;
6209
+ }
6210
+ function launchdLogPath(options, stream) {
6211
+ const fileName = `${options.serviceId}.${stream}.log`;
6212
+ if (options.mode === "system")
6213
+ return `/var/log/${fileName}`;
6214
+ return `$HOME/Library/Logs/${fileName}`;
6215
+ }
6216
+ function systemdUnitName(options) {
6217
+ return `${options.serviceId}.service`;
6218
+ }
6219
+ function systemdUnitPath(options) {
6220
+ if (options.mode === "system")
6221
+ return `/etc/systemd/system/${systemdUnitName(options)}`;
6222
+ return `$HOME/.config/systemd/user/${systemdUnitName(options)}`;
6223
+ }
6224
+ function xmlEscape(value) {
6225
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
6226
+ }
6227
+ function quoteSystemdExecArg(value) {
6228
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value) && !value.includes("%"))
6229
+ return value;
6230
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
6231
+ }
6232
+ function quoteSystemdEnvironment(name, value) {
6233
+ return `"${name}=${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
6234
+ }
6235
+ function basename(path) {
6236
+ return path.split("/").filter(Boolean).at(-1) || path;
6237
+ }
6238
+
5786
6239
  // src/commands/doctor.ts
5787
6240
  var DOCTOR_OPTIONAL_ADAPTER_DOMAINS = ["secrets", "configs", "monitor", "repos", "mcps", "shield"];
5788
6241
  function makeCheck(id, status, summary, detail, extra = {}) {
@@ -5873,14 +6326,20 @@ function runOptionalAdapterChecks(context, adapters) {
5873
6326
  }
5874
6327
  return checks;
5875
6328
  }
5876
- function runDoctor(machineId = getLocalMachineId(), options = {}) {
6329
+ function runDoctor(machineId, options = {}) {
6330
+ const implicitLocalMachine = !machineId;
6331
+ const requestedMachineId = machineId ?? getLocalMachineId();
6332
+ const reportedMachineId = implicitLocalMachine ? "local" : requestedMachineId;
5877
6333
  const now = options.now ?? new Date;
5878
6334
  const { manifest, info: manifestSource } = readManifestWithSource({ adapter: options.manifestAdapter ?? null });
5879
- const commandChecks = runMachineCommand(machineId, buildDoctorCommand());
6335
+ const commandChecks = runMachineCommand(requestedMachineId, buildDoctorCommand());
5880
6336
  const details = parseKeyValueOutput(commandChecks.stdout);
5881
- const machineInManifest = manifest.machines.find((machine) => machine.id === machineId);
6337
+ const machineInManifest = manifest.machines.find((machine) => machine.id === requestedMachineId);
6338
+ const diagnosticMachine = machineInManifest ? redactManifestForDiagnostics(machineInManifest) : null;
6339
+ if (implicitLocalMachine && diagnosticMachine)
6340
+ diagnosticMachine.id = reportedMachineId;
5882
6341
  const optionalAdapterChecks = options.includeOptionalAdapters === false ? [] : runOptionalAdapterChecks({
5883
- machineId,
6342
+ machineId: requestedMachineId,
5884
6343
  manifest,
5885
6344
  manifestSource,
5886
6345
  commandDetails: details,
@@ -5896,10 +6355,10 @@ function runDoctor(machineId = getLocalMachineId(), options = {}) {
5896
6355
  },
5897
6356
  remediation: manifestSource.warnings.length > 0 ? ["Provide a private manifest adapter or unset the private manifest ref to use the local manifest only."] : undefined
5898
6357
  }),
5899
- makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", machineInManifest ? JSON.stringify(redactManifestForDiagnostics(machineInManifest)) : `No manifest entry for ${machineId}`, {
6358
+ makeCheck("manifest-entry", machineInManifest ? "ok" : "warn", machineInManifest ? "Machine exists in manifest" : "Machine missing from manifest", diagnosticMachine ? JSON.stringify(diagnosticMachine) : `No manifest entry for ${reportedMachineId}`, {
5900
6359
  data: {
5901
6360
  declared: Boolean(machineInManifest),
5902
- machine: machineInManifest ? redactManifestForDiagnostics(machineInManifest) : null
6361
+ machine: diagnosticMachine
5903
6362
  }
5904
6363
  }),
5905
6364
  makeCheck("data-dir", details["data_dir_exists"] === "yes" ? "ok" : "warn", "Data directory check", `${redactPath(details["data_dir"] || "unknown")} ${details["data_dir_exists"] === "yes" ? "exists" : "missing"}`, {
@@ -5950,7 +6409,7 @@ function runDoctor(machineId = getLocalMachineId(), options = {}) {
5950
6409
  ...optionalAdapterChecks
5951
6410
  ];
5952
6411
  return {
5953
- machineId,
6412
+ machineId: reportedMachineId,
5954
6413
  source: commandChecks.source,
5955
6414
  schemaVersion: 1,
5956
6415
  generatedAt: now.toISOString(),
@@ -6197,8 +6656,8 @@ ${message}
6197
6656
  };
6198
6657
  }
6199
6658
  if (hasCommand2("mail")) {
6200
- const command = `printf %s ${shellQuote5(message)} | mail -s ${shellQuote5(subject)} ${shellQuote5(channel.target)}`;
6201
- const result = Bun.spawnSync(["bash", "-lc", command], {
6659
+ const command2 = `printf %s ${shellQuote5(message)} | mail -s ${shellQuote5(subject)} ${shellQuote5(channel.target)}`;
6660
+ const result = Bun.spawnSync(["bash", "-lc", command2], {
6202
6661
  stdout: "pipe",
6203
6662
  stderr: "pipe",
6204
6663
  env: process.env
@@ -6422,8 +6881,8 @@ function listPorts(machineId) {
6422
6881
  const targetMachineId = machineId || getLocalMachineId();
6423
6882
  const isLocal = targetMachineId === getLocalMachineId();
6424
6883
  const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
6425
- const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
6426
- const result = spawnSync3("bash", ["-lc", command], { encoding: "utf8" });
6884
+ const command2 = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
6885
+ const result = spawnSync3("bash", ["-lc", command2], { encoding: "utf8" });
6427
6886
  if (result.status !== 0) {
6428
6887
  throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
6429
6888
  }
@@ -6437,1160 +6896,1313 @@ function listPorts(machineId) {
6437
6896
  // src/commands/serve.ts
6438
6897
  import { EventsClient, sanitizeChannelsForOutput } from "@hasna/events";
6439
6898
 
6440
- // src/commands/manifest.ts
6441
- function manifestList() {
6442
- return readManifest();
6443
- }
6444
- function manifestAdd(machine) {
6445
- const validatedMachine = machineSchema.parse(machine);
6446
- const manifest = readManifest();
6447
- const nextMachines = manifest.machines.filter((entry) => entry.id !== validatedMachine.id);
6448
- nextMachines.push(validatedMachine);
6449
- const nextManifest = { ...manifest, machines: nextMachines };
6450
- writeManifest(nextManifest);
6451
- return nextManifest;
6452
- }
6453
- function manifestBootstrapCurrentMachine() {
6454
- return manifestAdd(detectCurrentMachineManifest());
6455
- }
6456
- function manifestGet(machineId) {
6457
- return getManifestMachine(machineId);
6899
+ // src/agent/runtime.ts
6900
+ import { arch as arch3, hostname as hostname6, platform as platform4, release, uptime, version as osVersion } from "os";
6901
+
6902
+ // src/pg-migrations.ts
6903
+ var PG_MIGRATIONS = [
6904
+ `
6905
+ CREATE TABLE IF NOT EXISTS agent_heartbeats (
6906
+ machine_id TEXT NOT NULL,
6907
+ pid INTEGER NOT NULL,
6908
+ status TEXT NOT NULL,
6909
+ updated_at TIMESTAMPTZ NOT NULL,
6910
+ daemon_version TEXT,
6911
+ agent_mode TEXT,
6912
+ platform TEXT,
6913
+ os_version TEXT,
6914
+ os_build TEXT,
6915
+ arch TEXT,
6916
+ uptime_seconds INTEGER,
6917
+ tool_versions_json TEXT,
6918
+ tailscale_json TEXT,
6919
+ storage_sync_status TEXT,
6920
+ storage_sync_last_error TEXT,
6921
+ doctor_summary_json TEXT,
6922
+ private_metadata INTEGER NOT NULL DEFAULT 0,
6923
+ PRIMARY KEY (machine_id, pid)
6924
+ );
6925
+
6926
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS daemon_version TEXT;
6927
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS agent_mode TEXT;
6928
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS platform TEXT;
6929
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS os_version TEXT;
6930
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS os_build TEXT;
6931
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS arch TEXT;
6932
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS uptime_seconds INTEGER;
6933
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS tool_versions_json TEXT;
6934
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS tailscale_json TEXT;
6935
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS storage_sync_status TEXT;
6936
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS storage_sync_last_error TEXT;
6937
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS doctor_summary_json TEXT;
6938
+ ALTER TABLE agent_heartbeats ADD COLUMN IF NOT EXISTS private_metadata INTEGER NOT NULL DEFAULT 0;
6939
+
6940
+ CREATE TABLE IF NOT EXISTS setup_runs (
6941
+ id TEXT PRIMARY KEY,
6942
+ machine_id TEXT NOT NULL,
6943
+ status TEXT NOT NULL,
6944
+ details_json TEXT NOT NULL DEFAULT '[]',
6945
+ created_at TIMESTAMPTZ NOT NULL,
6946
+ updated_at TIMESTAMPTZ NOT NULL
6947
+ );
6948
+
6949
+ CREATE TABLE IF NOT EXISTS sync_runs (
6950
+ id TEXT PRIMARY KEY,
6951
+ machine_id TEXT NOT NULL,
6952
+ status TEXT NOT NULL,
6953
+ actions_json TEXT NOT NULL DEFAULT '[]',
6954
+ created_at TIMESTAMPTZ NOT NULL,
6955
+ updated_at TIMESTAMPTZ NOT NULL
6956
+ );
6957
+ `
6958
+ ];
6959
+
6960
+ // src/remote-storage.ts
6961
+ import pg from "pg";
6962
+ function translatePlaceholders(sql) {
6963
+ let index = 0;
6964
+ return sql.replace(/\?/g, () => `$${++index}`);
6458
6965
  }
6459
- function manifestRemove(machineId) {
6460
- const manifest = readManifest();
6461
- const nextManifest = {
6462
- ...manifest,
6463
- machines: manifest.machines.filter((machine) => machine.id !== machineId)
6464
- };
6465
- writeManifest(nextManifest);
6466
- return nextManifest;
6966
+ function normalizeParams(params) {
6967
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
6968
+ return flat.map((value) => value === undefined ? null : value);
6467
6969
  }
6468
- function manifestValidate() {
6469
- return validateManifest(getManifestPath());
6970
+ function sslConfigFor(connectionString) {
6971
+ let url;
6972
+ try {
6973
+ url = new URL(connectionString);
6974
+ } catch {
6975
+ return;
6976
+ }
6977
+ const sslMode = url.searchParams.get("sslmode")?.toLowerCase();
6978
+ const ssl = url.searchParams.get("ssl")?.toLowerCase();
6979
+ if (sslMode === "disable" || ssl === "false")
6980
+ return;
6981
+ if (sslMode === "no-verify" || process.env["HASNA_MACHINES_DATABASE_SSL_REJECT_UNAUTHORIZED"] === "0") {
6982
+ return { rejectUnauthorized: false };
6983
+ }
6984
+ return sslMode || ssl === "true" ? { rejectUnauthorized: true } : undefined;
6470
6985
  }
6471
6986
 
6472
- // src/commands/status.ts
6473
- function getStatus() {
6474
- const manifest = readManifest();
6475
- const heartbeats = listHeartbeats();
6476
- const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
6477
- const machineIds = new Set([
6478
- ...manifest.machines.map((machine) => machine.id),
6479
- ...heartbeats.map((heartbeat) => heartbeat.machine_id)
6480
- ]);
6481
- return {
6482
- machineId: getLocalMachineId(),
6483
- manifestPath: getManifestPath(),
6484
- dbPath: getDbPath(),
6485
- notificationsPath: getNotificationsPath(),
6486
- manifestMachineCount: manifest.machines.length,
6487
- heartbeatCount: heartbeats.length,
6488
- machines: [...machineIds].sort().map((machineId) => {
6489
- const declared = manifest.machines.find((machine) => machine.id === machineId);
6490
- const heartbeat = heartbeatByMachine.get(machineId);
6491
- return {
6492
- machineId,
6493
- platform: declared?.platform,
6494
- manifestDeclared: Boolean(declared),
6495
- heartbeatStatus: heartbeat?.status || "unknown",
6496
- lastHeartbeatAt: heartbeat?.updated_at
6497
- };
6498
- }),
6499
- recentSetupRuns: countRuns("setup_runs"),
6500
- recentSyncRuns: countRuns("sync_runs")
6501
- };
6987
+ class PgAdapterAsync {
6988
+ pool;
6989
+ constructor(connectionString) {
6990
+ this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
6991
+ }
6992
+ async run(sql, ...params) {
6993
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
6994
+ return { changes: result.rowCount ?? 0 };
6995
+ }
6996
+ async all(sql, ...params) {
6997
+ const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
6998
+ return result.rows;
6999
+ }
7000
+ async close() {
7001
+ await this.pool.end();
7002
+ }
6502
7003
  }
6503
7004
 
6504
- // src/commands/self-test.ts
6505
- function check(id, status, summary, detail) {
6506
- return { id, status, summary, detail };
7005
+ // src/storage-sync.ts
7006
+ var STORAGE_TABLES = [
7007
+ "agent_heartbeats",
7008
+ "setup_runs",
7009
+ "sync_runs"
7010
+ ];
7011
+ var MACHINES_STORAGE_ENV = "HASNA_MACHINES_DATABASE_URL";
7012
+ var MACHINES_STORAGE_FALLBACK_ENV = "MACHINES_DATABASE_URL";
7013
+ var MACHINES_STORAGE_MODE_ENV = "HASNA_MACHINES_STORAGE_MODE";
7014
+ var MACHINES_STORAGE_MODE_FALLBACK_ENV = "MACHINES_STORAGE_MODE";
7015
+ var STORAGE_DATABASE_ENV = [MACHINES_STORAGE_ENV, MACHINES_STORAGE_FALLBACK_ENV];
7016
+ var PRIMARY_KEYS = {
7017
+ agent_heartbeats: ["machine_id", "pid"],
7018
+ setup_runs: ["id"],
7019
+ sync_runs: ["id"]
7020
+ };
7021
+ function readEnv2(name) {
7022
+ const value = process.env[name]?.trim();
7023
+ return value || undefined;
6507
7024
  }
6508
- function runSelfTest() {
6509
- const version = getPackageVersion();
6510
- const status = getStatus();
6511
- const doctor = runDoctor();
6512
- const serveInfo = getServeInfo();
6513
- const html = renderDashboardHtml();
6514
- const notifications = listNotificationChannels();
6515
- const apps = listApps(status.machineId);
6516
- const appsDiff = diffApps(status.machineId);
6517
- const cliPlan = buildClaudeInstallPlan(status.machineId);
6518
- return {
6519
- machineId: getLocalMachineId(),
6520
- checks: [
6521
- check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
6522
- check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
6523
- check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
6524
- check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
6525
- check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
6526
- check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
6527
- check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
6528
- check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
6529
- check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
6530
- ]
6531
- };
7025
+ function normalizeStorageMode(value) {
7026
+ const normalized = value?.trim().toLowerCase();
7027
+ if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
7028
+ return normalized;
7029
+ return;
6532
7030
  }
6533
-
6534
- // src/commands/serve.ts
6535
- function escapeHtml(value) {
6536
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
7031
+ function getStorageDatabaseEnvName() {
7032
+ for (const name of STORAGE_DATABASE_ENV) {
7033
+ if (readEnv2(name))
7034
+ return name;
7035
+ }
7036
+ return null;
6537
7037
  }
6538
- function getServeInfo(options = {}) {
6539
- const host = options.host || "0.0.0.0";
6540
- const port = options.port || 7676;
7038
+ function getStorageDatabaseEnv() {
7039
+ const name = getStorageDatabaseEnvName();
7040
+ return name ? { name } : null;
7041
+ }
7042
+ function getStorageDatabaseUrl() {
7043
+ const env = getStorageDatabaseEnv();
7044
+ return env ? readEnv2(env.name) ?? null : null;
7045
+ }
7046
+ function getStorageMode() {
7047
+ const mode = normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_FALLBACK_ENV));
7048
+ if (mode)
7049
+ return mode;
7050
+ return getStorageDatabaseUrl() ? "hybrid" : "local";
7051
+ }
7052
+ async function getStoragePg() {
7053
+ const url = getStorageDatabaseUrl();
7054
+ if (!url) {
7055
+ throw new Error("Missing HASNA_MACHINES_DATABASE_URL or MACHINES_DATABASE_URL");
7056
+ }
7057
+ return new PgAdapterAsync(url);
7058
+ }
7059
+ async function runStorageMigrations(remote) {
7060
+ for (const sql of PG_MIGRATIONS)
7061
+ await remote.run(sql);
7062
+ }
7063
+ async function storagePush(options) {
7064
+ const remote = await getStoragePg();
7065
+ const db = getDb();
7066
+ try {
7067
+ await runStorageMigrations(remote);
7068
+ const results = [];
7069
+ for (const table of resolveTables(options?.tables)) {
7070
+ results.push(await pushTable(db, remote, table));
7071
+ }
7072
+ recordSyncMeta(db, "push", results);
7073
+ return results;
7074
+ } finally {
7075
+ await remote.close();
7076
+ }
7077
+ }
7078
+ async function storagePull(options) {
7079
+ const remote = await getStoragePg();
7080
+ const db = getDb();
7081
+ try {
7082
+ await runStorageMigrations(remote);
7083
+ const results = [];
7084
+ for (const table of resolveTables(options?.tables)) {
7085
+ results.push(await pullTable(remote, db, table));
7086
+ }
7087
+ recordSyncMeta(db, "pull", results);
7088
+ return results;
7089
+ } finally {
7090
+ await remote.close();
7091
+ }
7092
+ }
7093
+ async function storageSync(options) {
7094
+ const pull = await storagePull(options);
7095
+ const push = await storagePush(options);
7096
+ return { pull, push };
7097
+ }
7098
+ function getSyncMetaAll() {
7099
+ const db = getDb();
7100
+ ensureSyncMetaTable(db);
7101
+ return db.query("SELECT table_name, last_synced_at, direction FROM _machines_sync_meta ORDER BY table_name, direction").all();
7102
+ }
7103
+ function getStorageStatus() {
7104
+ const activeEnv = getStorageDatabaseEnv();
6541
7105
  return {
6542
- host,
6543
- port,
6544
- url: `http://${host}:${port}`,
6545
- routes: [
6546
- "/",
6547
- "/health",
6548
- "/api/status",
6549
- "/api/manifest",
6550
- "/api/notifications",
6551
- "/api/webhooks",
6552
- "/api/events",
6553
- "/api/doctor",
6554
- "/api/self-test",
6555
- "/api/apps/status",
6556
- "/api/apps/diff",
6557
- "/api/install-claude/status",
6558
- "/api/install-claude/diff",
6559
- "/api/notifications/test",
6560
- "/api/webhooks/test"
6561
- ]
7106
+ configured: Boolean(activeEnv),
7107
+ mode: getStorageMode(),
7108
+ env: STORAGE_DATABASE_ENV,
7109
+ activeEnv: activeEnv?.name ?? null,
7110
+ service: "machines",
7111
+ tables: STORAGE_TABLES,
7112
+ sync: getSyncMetaAll()
6562
7113
  };
6563
7114
  }
6564
- function renderDashboardHtml() {
6565
- const status = getStatus();
6566
- const manifest = manifestList();
6567
- const notifications = listNotificationChannels();
6568
- const doctor = runDoctor();
6569
- return `<!doctype html>
6570
- <html lang="en">
6571
- <head>
6572
- <meta charset="utf-8" />
6573
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6574
- <title>Machines Dashboard</title>
6575
- <style>
6576
- :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
6577
- body { margin: 0; background: #0b1020; color: #e5ecff; }
6578
- main { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; }
6579
- h1, h2 { margin: 0 0 16px; }
6580
- .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
6581
- .card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
6582
- .stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
6583
- table { width: 100%; border-collapse: collapse; }
6584
- th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
6585
- code { color: #9ed0ff; }
6586
- .badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
6587
- .online, .ok { background: #12351f; color: #74f0a7; }
6588
- .offline, .fail { background: #3b1a1a; color: #ff8c8c; }
6589
- .unknown, .warn { background: #2f2b16; color: #ffd76a; }
6590
- ul { margin: 8px 0 0; padding-left: 18px; }
6591
- .muted { color: #9fb0d9; }
6592
- .refresh { font-size: 12px; color: #6b7fa3; margin-left: auto; }
6593
- .updated { transition: opacity 0.3s; }
6594
- </style>
6595
- </head>
6596
- <body>
6597
- <main>
6598
- <h1>Machines Dashboard <span class="refresh" id="last-updated"></span></h1>
6599
- <div class="grid">
6600
- <section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
6601
- <section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
6602
- <section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
6603
- <section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
6604
- </div>
6605
-
6606
- <section class="card" style="margin-top:16px">
6607
- <h2>Machines</h2>
6608
- <table>
6609
- <thead><tr><th>ID</th><th>Platform</th><th>Status</th><th>Last heartbeat</th></tr></thead>
6610
- <tbody>
6611
- ${status.machines.map((machine) => `<tr>
6612
- <td><code>${escapeHtml(machine.machineId)}</code></td>
6613
- <td>${escapeHtml(machine.platform || "unknown")}</td>
6614
- <td><span class="badge ${escapeHtml(machine.heartbeatStatus)}">${escapeHtml(machine.heartbeatStatus)}</span></td>
6615
- <td>${escapeHtml(machine.lastHeartbeatAt || "\u2014")}</td>
6616
- </tr>`).join("")}
6617
- </tbody>
6618
- </table>
6619
- </section>
6620
-
6621
- <section class="card" style="margin-top:16px">
6622
- <h2>Doctor</h2>
6623
- <table>
6624
- <thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
6625
- <tbody id="doctor-tbody">
6626
- ${doctor.checks.map((entry) => `<tr>
6627
- <td>${escapeHtml(entry.summary)}</td>
6628
- <td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
6629
- <td class="muted">${escapeHtml(entry.detail)}</td>
6630
- </tr>`).join("")}
6631
- </tbody>
6632
- </table>
6633
- </section>
6634
-
6635
- <section class="card" style="margin-top:16px">
6636
- <h2>Apps</h2>
6637
- <p class="muted">Use <code>/api/apps/status</code> for the full app inventory payload.</p>
6638
- </section>
6639
-
6640
- <section class="card" style="margin-top:16px">
6641
- <h2>AI CLIs</h2>
6642
- <p class="muted">Use <code>/api/install-claude/status</code> for the full CLI inventory payload.</p>
6643
- </section>
6644
-
6645
- <section class="card" style="margin-top:16px">
6646
- <h2>Self Test</h2>
6647
- <p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
6648
- </section>
6649
-
6650
- <section class="card" style="margin-top:16px">
6651
- <h2>Manifest</h2>
6652
- <pre id="manifest-json">${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
6653
- </section>
6654
- </main>
6655
- <script>
6656
- // Auto-refresh dashboard data every 15s
6657
- const REFRESH_INTERVAL = 15000;
6658
- async function refreshData() {
6659
- try {
6660
- const [statusRes, doctorRes] = await Promise.all([
6661
- fetch("/api/status"),
6662
- fetch("/api/doctor"),
6663
- ]);
6664
- const status = await statusRes.json();
6665
- const doctor = await doctorRes.json();
6666
-
6667
- // Update stat cards
6668
- const stats = document.querySelectorAll(".stat");
6669
- if (stats[0]) stats[0].textContent = status.manifestMachineCount;
6670
- if (stats[1]) stats[1].textContent = status.heartbeatCount;
6671
-
6672
- // Update machine table
6673
- const tbody = document.querySelector("tbody");
6674
- if (tbody && status.machines) {
6675
- tbody.innerHTML = status.machines
6676
- .map((m) =>
6677
- "<tr>" +
6678
- "<td><code>" + m.machineId + "</code></td>" +
6679
- "<td>" + (m.platform || "unknown") + "</td>" +
6680
- '<td><span class="badge ' + m.heartbeatStatus + '">' + m.heartbeatStatus + '</span></td>' +
6681
- "<td>" + (m.lastHeartbeatAt || "\\u2014") + "</td>" +
6682
- "</tr>"
6683
- )
6684
- .join("");
6685
- }
6686
-
6687
- // Update doctor table
6688
- const doctorTbody = document.getElementById("doctor-tbody");
6689
- if (doctorTbody && doctor.checks) {
6690
- doctorTbody.innerHTML = doctor.checks
6691
- .map((c) =>
6692
- "<tr>" +
6693
- "<td>" + c.summary + "</td>" +
6694
- '<td><span class="badge ' + c.status + '">' + c.status + '</span></td>' +
6695
- '<td class="muted">' + c.detail + "</td>" +
6696
- "</tr>"
6697
- )
6698
- .join("");
6699
- }
6700
-
6701
- // Update timestamp
6702
- document.getElementById("last-updated").textContent =
6703
- "updated " + new Date().toLocaleTimeString();
6704
- } catch (e) {
6705
- // Silently ignore fetch errors during page unload
6706
- }
6707
- }
6708
- document.getElementById("last-updated").textContent =
6709
- "updated " + new Date().toLocaleTimeString();
6710
- setInterval(refreshData, REFRESH_INTERVAL);
6711
- </script>
6712
- </body>
6713
- </html>`;
7115
+ function resolveTables(tables) {
7116
+ if (!tables || tables.length === 0)
7117
+ return [...STORAGE_TABLES];
7118
+ const allowed = new Set(STORAGE_TABLES);
7119
+ const requested = tables.map((table) => table.trim()).filter(Boolean);
7120
+ const invalid = requested.filter((table) => !allowed.has(table));
7121
+ if (invalid.length > 0)
7122
+ throw new Error(`Unknown machines storage table(s): ${invalid.join(", ")}`);
7123
+ return requested;
6714
7124
  }
6715
-
6716
- // src/commands/setup.ts
6717
- import { homedir as homedir4 } from "os";
6718
- function quote3(value) {
6719
- return `'${value.replace(/'/g, `'\\''`)}'`;
7125
+ async function pushTable(db, remote, table) {
7126
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
7127
+ try {
7128
+ if (!tableExists(db, table))
7129
+ return result;
7130
+ const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
7131
+ result.rowsRead = rows.length;
7132
+ if (rows.length === 0)
7133
+ return result;
7134
+ const remoteColumns = await getRemoteColumns(remote, table);
7135
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
7136
+ result.rowsWritten = await upsertPg(remote, table, columns, rows);
7137
+ } catch (error) {
7138
+ result.errors.push(error instanceof Error ? error.message : String(error));
7139
+ }
7140
+ return result;
6720
7141
  }
6721
- function buildBaseSteps(machine) {
6722
- const steps = [
6723
- {
6724
- id: "workspace",
6725
- title: "Ensure workspace directory exists",
6726
- command: `mkdir -p ${quote3(machine.workspacePath)}`,
6727
- manager: "shell"
6728
- },
6729
- {
6730
- id: "bun",
6731
- title: "Install Bun if missing",
6732
- command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
6733
- manager: "shell"
6734
- }
6735
- ];
6736
- if (machine.platform === "linux") {
6737
- steps.push({
6738
- id: "apt-base",
6739
- title: "Install core Linux tooling",
6740
- command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
6741
- manager: "apt",
6742
- privileged: true
6743
- });
6744
- steps.push({
6745
- id: "linux-update-downloads",
6746
- title: "Enable Linux package list refresh and download-only upgrades",
6747
- command: `printf '%s\\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Download-Upgradeable-Packages "1";' 'APT::Periodic::Unattended-Upgrade "0";' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null`,
6748
- manager: "apt",
6749
- privileged: true
6750
- });
6751
- } else if (machine.platform === "macos") {
6752
- steps.push({
6753
- id: "brew-base",
6754
- title: "Install Homebrew if missing",
6755
- command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
6756
- manager: "brew"
6757
- });
6758
- steps.push({
6759
- id: "brew-core",
6760
- title: "Install core macOS tooling",
6761
- command: "brew install git coreutils",
6762
- manager: "brew"
6763
- });
6764
- steps.push({
6765
- id: "macos-update-downloads",
6766
- title: "Enable macOS update checks and downloads without automatic install",
6767
- command: "sudo softwareupdate --schedule on && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -int 1 && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -int 0",
6768
- manager: "custom",
6769
- privileged: true
6770
- });
6771
- steps.push({
6772
- id: "macos-management-readiness",
6773
- title: "Report Apple management readiness without enrolling devices",
6774
- command: "profiles status -type enrollment 2>/dev/null || true",
6775
- manager: "custom"
6776
- });
7142
+ async function pullTable(remote, db, table) {
7143
+ const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
7144
+ try {
7145
+ if (!tableExists(db, table))
7146
+ return result;
7147
+ const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
7148
+ result.rowsRead = rows.length;
7149
+ if (rows.length === 0)
7150
+ return result;
7151
+ const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
7152
+ result.rowsWritten = upsertSqlite(db, table, columns, rows);
7153
+ } catch (error) {
7154
+ result.errors.push(error instanceof Error ? error.message : String(error));
6777
7155
  }
6778
- steps.push({
6779
- id: "github-app-auth-readiness",
6780
- title: "Check GitHub CLI/App auth readiness without printing credentials",
6781
- command: "command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 || true",
6782
- manager: "custom"
7156
+ return result;
7157
+ }
7158
+ async function getRemoteColumns(remote, table) {
7159
+ const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
7160
+ return new Set(rows.map((row) => row.column_name));
7161
+ }
7162
+ function filterRemoteColumns(remoteColumns, columns) {
7163
+ if (remoteColumns.size === 0)
7164
+ return columns;
7165
+ return columns.filter((column) => remoteColumns.has(column));
7166
+ }
7167
+ function filterLocalColumns(db, table, columns) {
7168
+ const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
7169
+ const allowed = new Set(rows.map((row) => row.name));
7170
+ return columns.filter((column) => allowed.has(column));
7171
+ }
7172
+ async function upsertPg(remote, table, columns, rows) {
7173
+ if (columns.length === 0)
7174
+ return 0;
7175
+ const primaryKeys = PRIMARY_KEYS[table];
7176
+ const columnList = columns.map(quoteIdent).join(", ");
7177
+ const placeholders = columns.map(() => "?").join(", ");
7178
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
7179
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
7180
+ const fallbackKey = primaryKeys[0];
7181
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
7182
+ for (const row of rows) {
7183
+ await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
7184
+ ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column])));
7185
+ }
7186
+ return rows.length;
7187
+ }
7188
+ function upsertSqlite(db, table, columns, rows) {
7189
+ if (columns.length === 0)
7190
+ return 0;
7191
+ const primaryKeys = PRIMARY_KEYS[table];
7192
+ const columnList = columns.map(quoteIdent).join(", ");
7193
+ const placeholders = columns.map(() => "?").join(", ");
7194
+ const keyList = primaryKeys.map(quoteIdent).join(", ");
7195
+ const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
7196
+ const fallbackKey = primaryKeys[0];
7197
+ const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
7198
+ const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
7199
+ ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
7200
+ const insert = db.transaction((batch) => {
7201
+ for (const row of batch)
7202
+ statement.run(...columns.map((column) => coerceForSqlite(row[column])));
6783
7203
  });
6784
- return steps;
7204
+ insert(rows);
7205
+ return rows.length;
6785
7206
  }
6786
- function buildPackageSteps(machine) {
6787
- return (machine.packages || []).map((pkg, index) => {
6788
- const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
6789
- let command = pkg.name;
6790
- if (manager === "bun") {
6791
- command = `bun install -g ${quote3(pkg.name)}`;
6792
- } else if (manager === "brew") {
6793
- command = `brew install ${quote3(pkg.name)}`;
6794
- } else if (manager === "apt") {
6795
- command = `sudo apt-get install -y ${quote3(pkg.name)}`;
6796
- }
6797
- return {
6798
- id: `package-${index + 1}`,
6799
- title: `Install package ${pkg.name}`,
6800
- command,
6801
- manager,
6802
- privileged: manager === "apt"
6803
- };
6804
- });
7207
+ function recordSyncMeta(db, direction, results) {
7208
+ ensureSyncMetaTable(db);
7209
+ const now = new Date().toISOString();
7210
+ const statement = db.query(`
7211
+ INSERT INTO _machines_sync_meta (table_name, last_synced_at, direction)
7212
+ VALUES (?, ?, ?)
7213
+ ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
7214
+ `);
7215
+ for (const result of results) {
7216
+ if (result.errors.length > 0)
7217
+ continue;
7218
+ statement.run(result.table, now, direction);
7219
+ }
6805
7220
  }
6806
- function buildSetupPlan(machineId) {
6807
- const manifest = readManifest();
6808
- const currentMachineId = getLocalMachineId();
6809
- const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
6810
- if (machineId && !selected) {
6811
- throw new Error(`Machine not found in manifest: ${machineId}`);
7221
+ function ensureSyncMetaTable(db) {
7222
+ db.exec(`
7223
+ CREATE TABLE IF NOT EXISTS _machines_sync_meta (
7224
+ table_name TEXT NOT NULL,
7225
+ last_synced_at TEXT,
7226
+ direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
7227
+ PRIMARY KEY (table_name, direction)
7228
+ )
7229
+ `);
7230
+ }
7231
+ function tableExists(db, table) {
7232
+ const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
7233
+ return Boolean(row);
7234
+ }
7235
+ function quoteIdent(identifier) {
7236
+ return `"${identifier.replace(/"/g, '""')}"`;
7237
+ }
7238
+ function coerceForPg(value) {
7239
+ if (value === undefined || value === null)
7240
+ return null;
7241
+ if (value instanceof Date)
7242
+ return value.toISOString();
7243
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
7244
+ return value;
7245
+ if (typeof value === "object")
7246
+ return JSON.stringify(value);
7247
+ return value;
7248
+ }
7249
+ function coerceForSqlite(value) {
7250
+ if (value === undefined || value === null)
7251
+ return null;
7252
+ if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
7253
+ return value;
7254
+ if (value instanceof Date)
7255
+ return value.toISOString();
7256
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
7257
+ return value;
7258
+ if (typeof value === "object")
7259
+ return JSON.stringify(value);
7260
+ return String(value);
7261
+ }
7262
+
7263
+ // src/agent/runtime.ts
7264
+ function parseJsonObject(value) {
7265
+ if (!value)
7266
+ return null;
7267
+ try {
7268
+ const parsed = JSON.parse(value);
7269
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
7270
+ } catch {
7271
+ return null;
6812
7272
  }
6813
- const target = selected || {
6814
- id: currentMachineId,
6815
- platform: "linux",
6816
- workspacePath: `${homedir4()}/workspace`
6817
- };
6818
- const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
7273
+ }
7274
+ function heartbeatToStatus(heartbeat, options = {}) {
7275
+ const privateMetadata = options.privateMetadata === true;
7276
+ const tailscale = parseJsonObject(heartbeat.tailscale_json);
7277
+ const doctorSummary = parseJsonObject(heartbeat.doctor_summary_json);
6819
7278
  return {
6820
- machineId: target.id,
6821
- mode: "plan",
6822
- steps,
6823
- executed: 0
7279
+ machineId: heartbeat.machine_id,
7280
+ pid: heartbeat.pid,
7281
+ status: heartbeat.status,
7282
+ updatedAt: heartbeat.updated_at,
7283
+ daemonVersion: heartbeat.daemon_version,
7284
+ agentMode: heartbeat.agent_mode,
7285
+ platform: heartbeat.platform,
7286
+ osVersion: heartbeat.os_version,
7287
+ osBuild: heartbeat.os_build,
7288
+ arch: heartbeat.arch,
7289
+ uptimeSeconds: heartbeat.uptime_seconds,
7290
+ toolVersions: sanitizeRecord(parseJsonObject(heartbeat.tool_versions_json) ?? {}, privateMetadata),
7291
+ tailscale: tailscale ? sanitizeRecord(tailscale, privateMetadata) : null,
7292
+ storageSyncStatus: heartbeat.storage_sync_status,
7293
+ storageSyncLastError: heartbeat.storage_sync_last_error ? sanitizeStorageError(heartbeat.storage_sync_last_error, privateMetadata) : null,
7294
+ doctorSummary: doctorSummary ? sanitizeRecord(doctorSummary, privateMetadata) : null,
7295
+ privateMetadata: Boolean(heartbeat.private_metadata)
6824
7296
  };
6825
7297
  }
6826
- function runSetup(machineId, options = {}, runner = runMachineCommand) {
6827
- const plan = buildSetupPlan(machineId);
6828
- if (!options.apply) {
6829
- return plan;
6830
- }
6831
- if (!options.yes) {
6832
- throw new Error("Setup execution requires --yes.");
6833
- }
6834
- let executed = 0;
6835
- for (const step of plan.steps) {
6836
- const result = runner(plan.machineId, step.command);
6837
- if (result.exitCode !== 0) {
6838
- recordSetupRun(plan.machineId, "failed", {
6839
- executed,
6840
- failedStep: step,
6841
- stderr: result.stderr,
6842
- stdout: result.stdout,
6843
- exitCode: result.exitCode,
6844
- source: result.source
6845
- });
6846
- throw new Error(describeMachineCommandFailure(`Setup step ${step.id}`, result));
7298
+ function sanitizePublicString(value, privateMetadata = false) {
7299
+ if (privateMetadata)
7300
+ return value;
7301
+ let redacted = value;
7302
+ const localHostname = hostname6();
7303
+ const localUser = process.env["USER"] || process.env["LOGNAME"] || process.env["USERNAME"];
7304
+ if (localHostname)
7305
+ redacted = redacted.replaceAll(localHostname, "[redacted-host]");
7306
+ if (localUser)
7307
+ redacted = redacted.replaceAll(localUser, "[redacted-user]");
7308
+ return redactErrorMessage(redacted.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s'")]+/gi, (match) => {
7309
+ const scheme = match.match(/^([a-z][a-z0-9+.-]*:\/\/)/i)?.[1] ?? "";
7310
+ return `${scheme}[redacted]`;
7311
+ }).replace(/\b10(?:\.\d{1,3}){3}\b/g, "[redacted-ip]").replace(/\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b/g, "[redacted-ip]").replace(/\b192\.168(?:\.\d{1,3}){2}\b/g, "[redacted-ip]").replace(/\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\b/g, "[redacted-ip]").replace(/\b(password|passwd|token|secret|api[_-]?key)=([^&\s]+)/gi, "$1=[redacted]"));
7312
+ }
7313
+ function sanitizeValue(value, privateMetadata) {
7314
+ if (typeof value === "string")
7315
+ return sanitizePublicString(value, privateMetadata);
7316
+ if (Array.isArray(value))
7317
+ return value.map((entry) => sanitizeValue(entry, privateMetadata));
7318
+ if (value && typeof value === "object")
7319
+ return sanitizeRecord(value, privateMetadata);
7320
+ return value;
7321
+ }
7322
+ function sanitizeRecord(value, privateMetadata) {
7323
+ const sanitized = {};
7324
+ for (const [key, entry] of Object.entries(value)) {
7325
+ if (!privateMetadata && /(hostname|hostName|user|username|serial|dnsName|ip|ips|databaseUrl|url|token|secret|password|credential)/i.test(key)) {
7326
+ sanitized[key] = "[redacted]";
7327
+ continue;
6847
7328
  }
6848
- executed += 1;
7329
+ sanitized[key] = sanitizeValue(entry, privateMetadata);
6849
7330
  }
6850
- const summary = {
6851
- machineId: plan.machineId,
6852
- mode: "apply",
6853
- steps: plan.steps,
6854
- executed
6855
- };
6856
- recordSetupRun(plan.machineId, "completed", summary);
6857
- return summary;
7331
+ return sanitized;
7332
+ }
7333
+ function sanitizeStorageError(message, privateMetadata) {
7334
+ return privateMetadata ? message : redactErrorMessage(message);
7335
+ }
7336
+ function getAgentStatus(machineId, options = {}) {
7337
+ return listHeartbeats(machineId).map((heartbeat) => heartbeatToStatus(heartbeat, options));
6858
7338
  }
6859
7339
 
6860
- // src/commands/sync.ts
6861
- import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
6862
- import { homedir as homedir5 } from "os";
6863
- function quote4(value) {
6864
- return `'${value.replace(/'/g, `'\\''`)}'`;
7340
+ // src/commands/manifest.ts
7341
+ function manifestList() {
7342
+ return readManifest();
6865
7343
  }
6866
- function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
6867
- const quotedPackageName = quote4(packageName);
6868
- if (manager === "bun") {
6869
- return `if bun pm ls -g --all 2>/dev/null | grep -F ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
6870
- }
6871
- if (manager === "brew") {
6872
- return `if brew list --versions ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
6873
- }
6874
- if (manager === "apt") {
6875
- return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
6876
- }
6877
- return `if command -v ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
7344
+ function manifestAdd(machine) {
7345
+ const validatedMachine = machineSchema.parse(machine);
7346
+ const manifest = readManifest();
7347
+ const nextMachines = manifest.machines.filter((entry) => entry.id !== validatedMachine.id);
7348
+ nextMachines.push(validatedMachine);
7349
+ const nextManifest = { ...manifest, machines: nextMachines };
7350
+ writeManifest(nextManifest);
7351
+ return nextManifest;
6878
7352
  }
6879
- function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
6880
- if (manager === "bun") {
6881
- return `bun install -g ${quote4(packageName)}`;
6882
- }
6883
- if (manager === "brew") {
6884
- return `brew install ${quote4(packageName)}`;
6885
- }
6886
- if (manager === "apt") {
6887
- return `sudo apt-get install -y ${quote4(packageName)}`;
6888
- }
6889
- return packageName;
7353
+ function manifestBootstrapCurrentMachine() {
7354
+ return manifestAdd(detectCurrentMachineManifest());
6890
7355
  }
6891
- function detectPackageActions(machine, runner) {
6892
- return (machine.packages || []).map((pkg, index) => {
6893
- const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
6894
- const check2 = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
6895
- if (check2.exitCode !== 0) {
6896
- throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check2));
6897
- }
6898
- const installed = check2.stdout.split(`
6899
- `).some((line) => line.trim() === "installed=1");
6900
- return {
6901
- id: `package-${index + 1}`,
6902
- title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
6903
- command: packageInstallCommand(machine, pkg.name, manager),
6904
- status: installed ? "ok" : "missing",
6905
- kind: "package"
6906
- };
6907
- });
7356
+ function manifestGet(machineId) {
7357
+ return getManifestMachine(machineId);
6908
7358
  }
6909
- function detectFileActions(machine) {
6910
- if ((machine.files || []).length > 0 && resolveMachineCommand(machine.id, "true").source !== "local") {
6911
- throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
7359
+ function manifestRemove(machineId) {
7360
+ const manifest = readManifest();
7361
+ const nextManifest = {
7362
+ ...manifest,
7363
+ machines: manifest.machines.filter((machine) => machine.id !== machineId)
7364
+ };
7365
+ writeManifest(nextManifest);
7366
+ return nextManifest;
7367
+ }
7368
+ function manifestValidate() {
7369
+ return validateManifest(getManifestPath());
7370
+ }
7371
+
7372
+ // src/commands/status.ts
7373
+ function parseJsonObject2(value) {
7374
+ if (!value)
7375
+ return null;
7376
+ try {
7377
+ const parsed = JSON.parse(value);
7378
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
7379
+ } catch {
7380
+ return null;
6912
7381
  }
6913
- return (machine.files || []).map((file, index) => {
6914
- const sourceExists = existsSync7(file.source);
6915
- const targetExists = existsSync7(file.target);
6916
- let status = "missing";
6917
- if (sourceExists && targetExists) {
6918
- if (file.mode === "symlink") {
6919
- status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
6920
- } else {
6921
- const source = readFileSync5(file.source, "utf8");
6922
- const target = readFileSync5(file.target, "utf8");
6923
- status = source === target ? "ok" : "drifted";
6924
- }
6925
- }
6926
- const command = file.mode === "symlink" ? `ln -sfn ${quote4(file.source)} ${quote4(file.target)}` : `cp ${quote4(file.source)} ${quote4(file.target)}`;
6927
- return {
6928
- id: `file-${index + 1}`,
6929
- title: `${status === "ok" ? "File in sync" : "Reconcile file"} ${file.target}`,
6930
- command,
6931
- status,
6932
- kind: "file"
6933
- };
6934
- });
6935
7382
  }
6936
- function buildSyncPlan(machineId, runner = runMachineCommand) {
7383
+ function getStatus() {
6937
7384
  const manifest = readManifest();
6938
- const currentMachineId = getLocalMachineId();
6939
- const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
6940
- if (machineId && !selected) {
6941
- throw new Error(`Machine not found in manifest: ${machineId}`);
6942
- }
6943
- const target = selected || {
6944
- id: currentMachineId,
6945
- platform: "linux",
6946
- workspacePath: `${homedir5()}/workspace`
7385
+ const heartbeats = listHeartbeats();
7386
+ const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
7387
+ const machineIds = new Set([
7388
+ ...manifest.machines.map((machine) => machine.id),
7389
+ ...heartbeats.map((heartbeat) => heartbeat.machine_id)
7390
+ ]);
7391
+ return {
7392
+ machineId: getLocalMachineId(),
7393
+ manifestPath: getManifestPath(),
7394
+ dbPath: getDbPath(),
7395
+ notificationsPath: getNotificationsPath(),
7396
+ manifestMachineCount: manifest.machines.length,
7397
+ heartbeatCount: heartbeats.length,
7398
+ machines: [...machineIds].sort().map((machineId) => {
7399
+ const declared = manifest.machines.find((machine) => machine.id === machineId);
7400
+ const heartbeat = heartbeatByMachine.get(machineId);
7401
+ return {
7402
+ machineId,
7403
+ platform: declared?.platform,
7404
+ manifestDeclared: Boolean(declared),
7405
+ heartbeatStatus: heartbeat?.status || "unknown",
7406
+ lastHeartbeatAt: heartbeat?.updated_at,
7407
+ daemonVersion: heartbeat?.daemon_version ?? null,
7408
+ agentMode: heartbeat?.agent_mode ?? null,
7409
+ storageSyncStatus: heartbeat?.storage_sync_status ?? null,
7410
+ doctorSummary: parseJsonObject2(heartbeat?.doctor_summary_json),
7411
+ privateMetadata: Boolean(heartbeat?.private_metadata)
7412
+ };
7413
+ }),
7414
+ recentSetupRuns: countRuns("setup_runs"),
7415
+ recentSyncRuns: countRuns("sync_runs")
6947
7416
  };
6948
- const actions = [
6949
- ...detectPackageActions(target, runner),
6950
- ...detectFileActions(target)
6951
- ];
7417
+ }
7418
+
7419
+ // src/commands/self-test.ts
7420
+ function check(id, status, summary, detail) {
7421
+ return { id, status, summary, detail };
7422
+ }
7423
+ function runSelfTest() {
7424
+ const version = getPackageVersion();
7425
+ const status = getStatus();
7426
+ const doctor = runDoctor();
7427
+ const serveInfo = getServeInfo();
7428
+ const html = renderDashboardHtml();
7429
+ const notifications = listNotificationChannels();
7430
+ const apps = listApps(status.machineId);
7431
+ const appsDiff = diffApps(status.machineId);
7432
+ const cliPlan = buildClaudeInstallPlan(status.machineId);
6952
7433
  return {
6953
- machineId: target.id,
6954
- mode: "plan",
6955
- actions,
6956
- executed: 0
7434
+ machineId: getLocalMachineId(),
7435
+ checks: [
7436
+ check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
7437
+ check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
7438
+ check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
7439
+ check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
7440
+ check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
7441
+ check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
7442
+ check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
7443
+ check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
7444
+ check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
7445
+ ]
6957
7446
  };
6958
7447
  }
6959
- function applyFileAction(command) {
6960
- const [verb, source, target] = command.split(" ");
6961
- if (verb === "cp" && source && target) {
6962
- ensureParentDir(target);
6963
- copyFileSync(source.slice(1, -1), target.slice(1, -1));
6964
- return;
6965
- }
6966
- if (verb === "ln" && source && target) {
6967
- const sourcePath = command.match(/ln -sfn '(.+)' '(.+)'/)?.[1];
6968
- const targetPath = command.match(/ln -sfn '(.+)' '(.+)'/)?.[2];
6969
- if (!sourcePath || !targetPath) {
6970
- throw new Error(`Unable to parse symlink command: ${command}`);
6971
- }
6972
- ensureParentDir(targetPath);
6973
- try {
6974
- Bun.file(targetPath).delete();
6975
- } catch {}
6976
- symlinkSync(sourcePath, targetPath);
6977
- }
7448
+
7449
+ // src/commands/serve.ts
7450
+ function escapeHtml(value) {
7451
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
6978
7452
  }
6979
- function runSync(machineId, options = {}, runner = runMachineCommand) {
6980
- const plan = buildSyncPlan(machineId, runner);
6981
- if (!options.apply) {
6982
- return plan;
6983
- }
6984
- if (!options.yes) {
6985
- throw new Error("Sync execution requires --yes.");
6986
- }
6987
- let executed = 0;
6988
- for (const action of plan.actions) {
6989
- if (action.status === "ok")
6990
- continue;
6991
- if (action.kind === "file") {
6992
- applyFileAction(action.command);
6993
- } else {
6994
- const result = runner(plan.machineId, action.command);
6995
- if (result.exitCode !== 0) {
6996
- recordSyncRun(plan.machineId, "failed", {
6997
- executed,
6998
- failedAction: action,
6999
- stderr: result.stderr,
7000
- stdout: result.stdout,
7001
- exitCode: result.exitCode,
7002
- source: result.source
7003
- });
7004
- throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
7005
- }
7006
- }
7007
- executed += 1;
7008
- }
7009
- const summary = {
7010
- machineId: plan.machineId,
7011
- mode: "apply",
7012
- actions: plan.actions,
7013
- executed
7453
+ function getServeInfo(options = {}) {
7454
+ const host = options.host || "0.0.0.0";
7455
+ const port = options.port || 7676;
7456
+ return {
7457
+ host,
7458
+ port,
7459
+ url: `http://${host}:${port}`,
7460
+ routes: [
7461
+ "/",
7462
+ "/health",
7463
+ "/api/status",
7464
+ "/api/topology",
7465
+ "/api/routes",
7466
+ "/api/daemon/status",
7467
+ "/api/manifest",
7468
+ "/api/notifications",
7469
+ "/api/webhooks",
7470
+ "/api/events",
7471
+ "/api/doctor",
7472
+ "/api/self-test",
7473
+ "/api/apps/status",
7474
+ "/api/apps/diff",
7475
+ "/api/install-claude/status",
7476
+ "/api/install-claude/diff",
7477
+ "/api/notifications/test",
7478
+ "/api/webhooks/test"
7479
+ ]
7014
7480
  };
7015
- recordSyncRun(plan.machineId, "completed", summary);
7016
- return summary;
7017
7481
  }
7482
+ function renderDashboardHtml() {
7483
+ const status = getStatus();
7484
+ const topology = discoverMachineTopology();
7485
+ const manifest = manifestList();
7486
+ const notifications = listNotificationChannels();
7487
+ const doctor = runDoctor();
7488
+ return `<!doctype html>
7489
+ <html lang="en">
7490
+ <head>
7491
+ <meta charset="utf-8" />
7492
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7493
+ <title>Machines Dashboard</title>
7494
+ <style>
7495
+ :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
7496
+ body { margin: 0; background: #0b1020; color: #e5ecff; }
7497
+ main { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; }
7498
+ h1, h2 { margin: 0 0 16px; }
7499
+ .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
7500
+ .card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
7501
+ .stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
7502
+ table { width: 100%; border-collapse: collapse; }
7503
+ th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
7504
+ code { color: #9ed0ff; }
7505
+ .badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
7506
+ .online, .ok { background: #12351f; color: #74f0a7; }
7507
+ .offline, .fail { background: #3b1a1a; color: #ff8c8c; }
7508
+ .unknown, .warn { background: #2f2b16; color: #ffd76a; }
7509
+ ul { margin: 8px 0 0; padding-left: 18px; }
7510
+ .muted { color: #9fb0d9; }
7511
+ .refresh { font-size: 12px; color: #6b7fa3; margin-left: auto; }
7512
+ .updated { transition: opacity 0.3s; }
7513
+ </style>
7514
+ </head>
7515
+ <body>
7516
+ <main>
7517
+ <h1>Machines Dashboard <span class="refresh" id="last-updated"></span></h1>
7518
+ <div class="grid">
7519
+ <section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
7520
+ <section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
7521
+ <section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
7522
+ <section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
7523
+ <section class="card"><div>Tailscale routes</div><div class="stat">${topology.machines.filter((machine) => machine.ssh.route === "tailscale").length}</div></section>
7524
+ </div>
7525
+
7526
+ <section class="card" style="margin-top:16px">
7527
+ <h2>Machines</h2>
7528
+ <table>
7529
+ <thead><tr><th>ID</th><th>Platform</th><th>Status</th><th>Agent</th><th>Storage</th><th>Last heartbeat</th></tr></thead>
7530
+ <tbody>
7531
+ ${status.machines.map((machine) => `<tr>
7532
+ <td><code>${escapeHtml(machine.machineId)}</code></td>
7533
+ <td>${escapeHtml(machine.platform || "unknown")}</td>
7534
+ <td><span class="badge ${escapeHtml(machine.heartbeatStatus)}">${escapeHtml(machine.heartbeatStatus)}</span></td>
7535
+ <td>${escapeHtml(machine.agentMode || "unknown")} ${escapeHtml(machine.daemonVersion || "")}</td>
7536
+ <td>${escapeHtml(machine.storageSyncStatus || "unknown")}</td>
7537
+ <td>${escapeHtml(machine.lastHeartbeatAt || "\u2014")}</td>
7538
+ </tr>`).join("")}
7539
+ </tbody>
7540
+ </table>
7541
+ </section>
7542
+
7543
+ <section class="card" style="margin-top:16px">
7544
+ <h2>Doctor</h2>
7545
+ <table>
7546
+ <thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
7547
+ <tbody id="doctor-tbody">
7548
+ ${doctor.checks.map((entry) => `<tr>
7549
+ <td>${escapeHtml(entry.summary)}</td>
7550
+ <td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
7551
+ <td class="muted">${escapeHtml(entry.detail)}</td>
7552
+ </tr>`).join("")}
7553
+ </tbody>
7554
+ </table>
7555
+ </section>
7556
+
7557
+ <section class="card" style="margin-top:16px">
7558
+ <h2>Apps</h2>
7559
+ <p class="muted">Use <code>/api/apps/status</code> for the full app inventory payload.</p>
7560
+ </section>
7561
+
7562
+ <section class="card" style="margin-top:16px">
7563
+ <h2>AI CLIs</h2>
7564
+ <p class="muted">Use <code>/api/install-claude/status</code> for the full CLI inventory payload.</p>
7565
+ </section>
7566
+
7567
+ <section class="card" style="margin-top:16px">
7568
+ <h2>Self Test</h2>
7569
+ <p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
7570
+ </section>
7571
+
7572
+ <section class="card" style="margin-top:16px">
7573
+ <h2>Manifest</h2>
7574
+ <pre id="manifest-json">${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
7575
+ </section>
7576
+ </main>
7577
+ <script>
7578
+ // Auto-refresh dashboard data every 15s
7579
+ const REFRESH_INTERVAL = 15000;
7580
+ function escapeHtml(value) {
7581
+ return String(value ?? "")
7582
+ .replaceAll("&", "&amp;")
7583
+ .replaceAll("<", "&lt;")
7584
+ .replaceAll(">", "&gt;")
7585
+ .replaceAll('"', "&quot;")
7586
+ .replaceAll("'", "&#39;");
7587
+ }
7588
+ async function refreshData() {
7589
+ try {
7590
+ const [statusRes, doctorRes] = await Promise.all([
7591
+ fetch("/api/status"),
7592
+ fetch("/api/doctor"),
7593
+ ]);
7594
+ const status = await statusRes.json();
7595
+ const doctor = await doctorRes.json();
7596
+
7597
+ // Update stat cards
7598
+ const stats = document.querySelectorAll(".stat");
7599
+ if (stats[0]) stats[0].textContent = status.manifestMachineCount;
7600
+ if (stats[1]) stats[1].textContent = status.heartbeatCount;
7601
+
7602
+ // Update machine table
7603
+ const tbody = document.querySelector("tbody");
7604
+ if (tbody && status.machines) {
7605
+ tbody.innerHTML = status.machines
7606
+ .map((m) =>
7607
+ "<tr>" +
7608
+ "<td><code>" + escapeHtml(m.machineId) + "</code></td>" +
7609
+ "<td>" + escapeHtml(m.platform || "unknown") + "</td>" +
7610
+ '<td><span class="badge ' + escapeHtml(m.heartbeatStatus) + '">' + escapeHtml(m.heartbeatStatus) + '</span></td>' +
7611
+ "<td>" + escapeHtml(m.agentMode || "unknown") + " " + escapeHtml(m.daemonVersion || "") + "</td>" +
7612
+ "<td>" + escapeHtml(m.storageSyncStatus || "unknown") + "</td>" +
7613
+ "<td>" + escapeHtml(m.lastHeartbeatAt || "\\u2014") + "</td>" +
7614
+ "</tr>"
7615
+ )
7616
+ .join("");
7617
+ }
7618
+
7619
+ // Update doctor table
7620
+ const doctorTbody = document.getElementById("doctor-tbody");
7621
+ if (doctorTbody && doctor.checks) {
7622
+ doctorTbody.innerHTML = doctor.checks
7623
+ .map((c) =>
7624
+ "<tr>" +
7625
+ "<td>" + escapeHtml(c.summary) + "</td>" +
7626
+ '<td><span class="badge ' + escapeHtml(c.status) + '">' + escapeHtml(c.status) + '</span></td>' +
7627
+ '<td class="muted">' + escapeHtml(c.detail) + "</td>" +
7628
+ "</tr>"
7629
+ )
7630
+ .join("");
7631
+ }
7018
7632
 
7019
- // src/agent/runtime.ts
7020
- function getAgentStatus(machineId = getLocalMachineId()) {
7021
- return listHeartbeats(machineId).map((heartbeat) => ({
7022
- machineId: heartbeat.machine_id,
7023
- pid: heartbeat.pid,
7024
- status: heartbeat.status,
7025
- updatedAt: heartbeat.updated_at
7026
- }));
7633
+ // Update timestamp
7634
+ document.getElementById("last-updated").textContent =
7635
+ "updated " + new Date().toLocaleTimeString();
7636
+ } catch (e) {
7637
+ // Silently ignore fetch errors during page unload
7638
+ }
7639
+ }
7640
+ document.getElementById("last-updated").textContent =
7641
+ "updated " + new Date().toLocaleTimeString();
7642
+ setInterval(refreshData, REFRESH_INTERVAL);
7643
+ </script>
7644
+ </body>
7645
+ </html>`;
7027
7646
  }
7028
7647
 
7029
- // src/compatibility.ts
7030
- var DEFAULT_COMMANDS = [
7031
- { command: "bun", required: true },
7032
- { command: "machines", required: true }
7033
- ];
7034
- function defaultPackages() {
7035
- return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
7036
- }
7037
- function shellQuote6(value) {
7038
- return `'${value.replace(/'/g, "'\\''")}'`;
7039
- }
7040
- function commandId(value) {
7041
- return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
7042
- }
7043
- function packageCommand(name) {
7044
- if (name === "@hasna/knowledge")
7045
- return "knowledge";
7046
- if (name === "@hasna/machines")
7047
- return "machines";
7048
- return name.split("/").pop() ?? name;
7049
- }
7050
- function firstLine(value) {
7051
- return value.trim().split(/\r?\n/).find(Boolean) ?? "";
7052
- }
7053
- function extractVersion(value) {
7054
- const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
7055
- return match?.[0] ?? null;
7056
- }
7057
- function statusFor(required, ok) {
7058
- if (ok)
7059
- return "ok";
7060
- return required === false ? "warn" : "fail";
7061
- }
7062
- function makeCheck2(input) {
7063
- return {
7064
- id: input.id,
7065
- kind: input.kind,
7066
- status: input.status,
7067
- target: input.target,
7068
- expected: input.expected ?? null,
7069
- actual: input.actual ?? null,
7070
- detail: input.detail,
7071
- source: input.source
7072
- };
7073
- }
7074
- function parseKeyValue(stdout) {
7075
- const result = {};
7076
- for (const line of stdout.split(/\r?\n/)) {
7077
- const idx = line.indexOf("=");
7078
- if (idx <= 0)
7079
- continue;
7080
- result[line.slice(0, idx)] = line.slice(idx + 1);
7081
- }
7082
- return result;
7083
- }
7084
- function defaultRunner2(machineId, command) {
7085
- return runMachineCommand(machineId, command);
7086
- }
7087
- function inspectCommand(machineId, spec, runner) {
7088
- const command = shellQuote6(spec.command);
7089
- const versionArgs = spec.versionArgs ?? "--version";
7090
- const script = [
7091
- `cmd=${command}`,
7092
- 'path="$(command -v "$cmd" 2>/dev/null || true)"',
7093
- 'printf "path=%s\\n" "$path"',
7094
- 'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
7095
- ].join("; ");
7096
- const result = runner(machineId, script);
7097
- const parsed = parseKeyValue(result.stdout);
7098
- return {
7099
- path: parsed.path || null,
7100
- version: parsed.version ? firstLine(parsed.version) : null,
7101
- exitCode: result.exitCode,
7102
- source: result.source,
7103
- stderr: result.stderr
7104
- };
7105
- }
7106
- function fieldCommand(field) {
7107
- const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
7108
- return [
7109
- `if command -v bun >/dev/null 2>&1; then bun -e "const p=JSON.parse(await Bun.file(process.argv[1]).text()); console.log(p.${field} ?? '')" "$pkg" 2>/dev/null`,
7110
- `elif command -v node >/dev/null 2>&1; then node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(p.${field} || '')" "$pkg" 2>/dev/null`,
7111
- `else sed -n '${regex}' "$pkg" | head -n 1`,
7112
- "fi"
7113
- ].join("; ");
7114
- }
7115
- function inspectWorkspace(machineId, spec, runner) {
7116
- const script = [
7117
- `path=${shellQuote6(spec.path)}`,
7118
- 'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
7119
- 'pkg="$path/package.json"',
7120
- 'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
7121
- `if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
7122
- ].join("; ");
7123
- const result = runner(machineId, script);
7124
- const parsed = parseKeyValue(result.stdout);
7125
- return {
7126
- exists: parsed.exists === "yes",
7127
- packageJson: parsed.package_json === "yes",
7128
- packageName: parsed.package_name || null,
7129
- version: parsed.version || null,
7130
- source: result.source,
7131
- stderr: result.stderr
7132
- };
7648
+ // src/commands/setup.ts
7649
+ import { homedir as homedir4 } from "os";
7650
+ function quote3(value) {
7651
+ return `'${value.replace(/'/g, `'\\''`)}'`;
7133
7652
  }
7134
- function commandCheck(machineId, spec, runner) {
7135
- const inspection = inspectCommand(machineId, spec, runner);
7136
- const found = Boolean(inspection.path);
7137
- const checks = [
7138
- makeCheck2({
7139
- id: `command:${commandId(spec.command)}:path`,
7140
- kind: "command",
7141
- status: statusFor(spec.required, found),
7142
- target: spec.command,
7143
- expected: "available",
7144
- actual: inspection.path ?? "missing",
7145
- detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
7146
- source: inspection.source
7147
- })
7653
+ function buildBaseSteps(machine) {
7654
+ const steps = [
7655
+ {
7656
+ id: "workspace",
7657
+ title: "Ensure workspace directory exists",
7658
+ command: `mkdir -p ${quote3(machine.workspacePath)}`,
7659
+ manager: "shell"
7660
+ },
7661
+ {
7662
+ id: "bun",
7663
+ title: "Install Bun if missing",
7664
+ command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
7665
+ manager: "shell"
7666
+ }
7148
7667
  ];
7149
- if (spec.expectedVersion) {
7150
- const actualVersion = extractVersion(inspection.version ?? "");
7151
- checks.push(makeCheck2({
7152
- id: `command:${commandId(spec.command)}:version`,
7153
- kind: "command",
7154
- status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
7155
- target: spec.command,
7156
- expected: spec.expectedVersion,
7157
- actual: actualVersion ?? inspection.version ?? "missing",
7158
- detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
7159
- source: inspection.source
7160
- }));
7668
+ if (machine.platform === "linux") {
7669
+ steps.push({
7670
+ id: "apt-base",
7671
+ title: "Install core Linux tooling",
7672
+ command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
7673
+ manager: "apt",
7674
+ privileged: true
7675
+ });
7676
+ steps.push({
7677
+ id: "linux-update-downloads",
7678
+ title: "Enable Linux package list refresh and download-only upgrades",
7679
+ command: `printf '%s\\n' 'APT::Periodic::Update-Package-Lists "1";' 'APT::Periodic::Download-Upgradeable-Packages "1";' 'APT::Periodic::Unattended-Upgrade "0";' | sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null`,
7680
+ manager: "apt",
7681
+ privileged: true
7682
+ });
7683
+ } else if (machine.platform === "macos") {
7684
+ steps.push({
7685
+ id: "brew-base",
7686
+ title: "Install Homebrew if missing",
7687
+ command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
7688
+ manager: "brew"
7689
+ });
7690
+ steps.push({
7691
+ id: "brew-core",
7692
+ title: "Install core macOS tooling",
7693
+ command: "brew install git coreutils",
7694
+ manager: "brew"
7695
+ });
7696
+ steps.push({
7697
+ id: "macos-update-downloads",
7698
+ title: "Enable macOS update checks and downloads without automatic install",
7699
+ command: "sudo softwareupdate --schedule on && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticCheckEnabled -bool true && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticDownload -int 1 && sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate AutomaticallyInstallMacOSUpdates -int 0",
7700
+ manager: "custom",
7701
+ privileged: true
7702
+ });
7703
+ steps.push({
7704
+ id: "macos-management-readiness",
7705
+ title: "Report Apple management readiness without enrolling devices",
7706
+ command: "profiles status -type enrollment 2>/dev/null || true",
7707
+ manager: "custom"
7708
+ });
7161
7709
  }
7162
- return checks;
7710
+ steps.push({
7711
+ id: "github-app-auth-readiness",
7712
+ title: "Check GitHub CLI/App auth readiness without printing credentials",
7713
+ command: "command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 || true",
7714
+ manager: "custom"
7715
+ });
7716
+ return steps;
7163
7717
  }
7164
- function packageCheck(machineId, spec, runner) {
7165
- const command = spec.command ?? packageCommand(spec.name);
7166
- const inspection = inspectCommand(machineId, { command, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
7167
- const found = Boolean(inspection.path);
7168
- const checks = [
7169
- makeCheck2({
7170
- id: `package:${commandId(spec.name)}:command`,
7171
- kind: "package",
7172
- status: statusFor(spec.required, found),
7173
- target: spec.name,
7174
- expected: command,
7175
- actual: inspection.path ?? "missing",
7176
- detail: found ? `${command} found at ${inspection.path}` : `${command} command missing`,
7177
- source: inspection.source
7178
- })
7179
- ];
7180
- if (spec.expectedVersion) {
7181
- const actualVersion = extractVersion(inspection.version ?? "");
7182
- checks.push(makeCheck2({
7183
- id: `package:${commandId(spec.name)}:version`,
7184
- kind: "package",
7185
- status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
7186
- target: spec.name,
7187
- expected: spec.expectedVersion,
7188
- actual: actualVersion ?? inspection.version ?? "missing",
7189
- detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
7190
- source: inspection.source
7191
- }));
7718
+ function buildPackageSteps(machine) {
7719
+ return (machine.packages || []).map((pkg, index) => {
7720
+ const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
7721
+ let command2 = pkg.name;
7722
+ if (manager === "bun") {
7723
+ command2 = `bun install -g ${quote3(pkg.name)}`;
7724
+ } else if (manager === "brew") {
7725
+ command2 = `brew install ${quote3(pkg.name)}`;
7726
+ } else if (manager === "apt") {
7727
+ command2 = `sudo apt-get install -y ${quote3(pkg.name)}`;
7728
+ }
7729
+ return {
7730
+ id: `package-${index + 1}`,
7731
+ title: `Install package ${pkg.name}`,
7732
+ command: command2,
7733
+ manager,
7734
+ privileged: manager === "apt"
7735
+ };
7736
+ });
7737
+ }
7738
+ function buildSetupPlan(machineId) {
7739
+ const manifest = readManifest();
7740
+ const currentMachineId = getLocalMachineId();
7741
+ const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
7742
+ if (machineId && !selected) {
7743
+ throw new Error(`Machine not found in manifest: ${machineId}`);
7192
7744
  }
7193
- return checks;
7745
+ const target = selected || {
7746
+ id: currentMachineId,
7747
+ platform: "linux",
7748
+ workspacePath: `${homedir4()}/workspace`
7749
+ };
7750
+ const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
7751
+ return {
7752
+ machineId: target.id,
7753
+ mode: "plan",
7754
+ steps,
7755
+ executed: 0
7756
+ };
7194
7757
  }
7195
- function workspaceCheck(machineId, spec, runner) {
7196
- const inspection = inspectWorkspace(machineId, spec, runner);
7197
- const target = spec.label ?? spec.path;
7198
- const checks = [
7199
- makeCheck2({
7200
- id: `workspace:${commandId(target)}:path`,
7201
- kind: "workspace",
7202
- status: statusFor(spec.required, inspection.exists),
7203
- target,
7204
- expected: spec.path,
7205
- actual: inspection.exists ? "exists" : "missing",
7206
- detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
7207
- source: inspection.source
7208
- })
7209
- ];
7210
- if (spec.expectedPackageName) {
7211
- checks.push(makeCheck2({
7212
- id: `workspace:${commandId(target)}:package-name`,
7213
- kind: "workspace",
7214
- status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
7215
- target,
7216
- expected: spec.expectedPackageName,
7217
- actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
7218
- detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
7219
- source: inspection.source
7220
- }));
7758
+ function runSetup(machineId, options = {}, runner = runMachineCommand) {
7759
+ const plan = buildSetupPlan(machineId);
7760
+ if (!options.apply) {
7761
+ return plan;
7221
7762
  }
7222
- if (spec.expectedVersion) {
7223
- checks.push(makeCheck2({
7224
- id: `workspace:${commandId(target)}:version`,
7225
- kind: "workspace",
7226
- status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
7227
- target,
7228
- expected: spec.expectedVersion,
7229
- actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
7230
- detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
7231
- source: inspection.source
7232
- }));
7763
+ if (!options.yes) {
7764
+ throw new Error("Setup execution requires --yes.");
7765
+ }
7766
+ let executed = 0;
7767
+ for (const step of plan.steps) {
7768
+ const result = runner(plan.machineId, step.command);
7769
+ if (result.exitCode !== 0) {
7770
+ recordSetupRun(plan.machineId, "failed", {
7771
+ executed,
7772
+ failedStep: step,
7773
+ stderr: result.stderr,
7774
+ stdout: result.stdout,
7775
+ exitCode: result.exitCode,
7776
+ source: result.source
7777
+ });
7778
+ throw new Error(describeMachineCommandFailure(`Setup step ${step.id}`, result));
7779
+ }
7780
+ executed += 1;
7233
7781
  }
7234
- return checks;
7235
- }
7236
- function checkMachineCompatibility(options = {}) {
7237
- const machineId = options.machineId ?? getLocalMachineId();
7238
- const runner = options.runner ?? defaultRunner2;
7239
- const commands = options.commands ?? DEFAULT_COMMANDS;
7240
- const packages = options.packages ?? defaultPackages();
7241
- const workspaces = options.workspaces ?? [];
7242
- const checks = [];
7243
- for (const spec of commands)
7244
- checks.push(...commandCheck(machineId, spec, runner));
7245
- for (const spec of packages)
7246
- checks.push(...packageCheck(machineId, spec, runner));
7247
- for (const spec of workspaces)
7248
- checks.push(...workspaceCheck(machineId, spec, runner));
7249
7782
  const summary = {
7250
- ok: checks.filter((check2) => check2.status === "ok").length,
7251
- warn: checks.filter((check2) => check2.status === "warn").length,
7252
- fail: checks.filter((check2) => check2.status === "fail").length
7253
- };
7254
- return {
7255
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
7256
- package: {
7257
- name: MACHINES_PACKAGE_NAME,
7258
- version: getPackageVersion()
7259
- },
7260
- capabilities: getMachinesConsumerCapabilities(),
7261
- ok: summary.fail === 0,
7262
- machine_id: machineId,
7263
- source: checks[0]?.source ?? "local",
7264
- generated_at: (options.now ?? new Date).toISOString(),
7265
- checks,
7266
- summary
7783
+ machineId: plan.machineId,
7784
+ mode: "apply",
7785
+ steps: plan.steps,
7786
+ executed
7267
7787
  };
7788
+ recordSetupRun(plan.machineId, "completed", summary);
7789
+ return summary;
7268
7790
  }
7269
7791
 
7270
- // src/pg-migrations.ts
7271
- var PG_MIGRATIONS = [
7272
- `
7273
- CREATE TABLE IF NOT EXISTS agent_heartbeats (
7274
- machine_id TEXT NOT NULL,
7275
- pid INTEGER NOT NULL,
7276
- status TEXT NOT NULL,
7277
- updated_at TIMESTAMPTZ NOT NULL,
7278
- PRIMARY KEY (machine_id, pid)
7279
- );
7280
-
7281
- CREATE TABLE IF NOT EXISTS setup_runs (
7282
- id TEXT PRIMARY KEY,
7283
- machine_id TEXT NOT NULL,
7284
- status TEXT NOT NULL,
7285
- details_json TEXT NOT NULL DEFAULT '[]',
7286
- created_at TIMESTAMPTZ NOT NULL,
7287
- updated_at TIMESTAMPTZ NOT NULL
7288
- );
7289
-
7290
- CREATE TABLE IF NOT EXISTS sync_runs (
7291
- id TEXT PRIMARY KEY,
7292
- machine_id TEXT NOT NULL,
7293
- status TEXT NOT NULL,
7294
- actions_json TEXT NOT NULL DEFAULT '[]',
7295
- created_at TIMESTAMPTZ NOT NULL,
7296
- updated_at TIMESTAMPTZ NOT NULL
7297
- );
7298
- `
7299
- ];
7300
-
7301
- // src/remote-storage.ts
7302
- import pg from "pg";
7303
- function translatePlaceholders(sql) {
7304
- let index = 0;
7305
- return sql.replace(/\?/g, () => `$${++index}`);
7306
- }
7307
- function normalizeParams(params) {
7308
- const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
7309
- return flat.map((value) => value === undefined ? null : value);
7310
- }
7311
- function sslConfigFor(connectionString) {
7312
- return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
7792
+ // src/commands/sync.ts
7793
+ import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
7794
+ import { homedir as homedir5 } from "os";
7795
+ function quote4(value) {
7796
+ return `'${value.replace(/'/g, `'\\''`)}'`;
7313
7797
  }
7314
-
7315
- class PgAdapterAsync {
7316
- pool;
7317
- constructor(connectionString) {
7318
- this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
7798
+ function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
7799
+ const quotedPackageName = quote4(packageName);
7800
+ if (manager === "bun") {
7801
+ return `if bun pm ls -g --all 2>/dev/null | grep -F ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
7319
7802
  }
7320
- async run(sql, ...params) {
7321
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
7322
- return { changes: result.rowCount ?? 0 };
7803
+ if (manager === "brew") {
7804
+ return `if brew list --versions ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
7323
7805
  }
7324
- async all(sql, ...params) {
7325
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
7326
- return result.rows;
7806
+ if (manager === "apt") {
7807
+ return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
7327
7808
  }
7328
- async close() {
7329
- await this.pool.end();
7809
+ return `if command -v ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
7810
+ }
7811
+ function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
7812
+ if (manager === "bun") {
7813
+ return `bun install -g ${quote4(packageName)}`;
7814
+ }
7815
+ if (manager === "brew") {
7816
+ return `brew install ${quote4(packageName)}`;
7817
+ }
7818
+ if (manager === "apt") {
7819
+ return `sudo apt-get install -y ${quote4(packageName)}`;
7330
7820
  }
7821
+ return packageName;
7331
7822
  }
7332
-
7333
- // src/storage-sync.ts
7334
- var STORAGE_TABLES = [
7335
- "agent_heartbeats",
7336
- "setup_runs",
7337
- "sync_runs"
7338
- ];
7339
- var MACHINES_STORAGE_ENV = "HASNA_MACHINES_DATABASE_URL";
7340
- var MACHINES_STORAGE_FALLBACK_ENV = "MACHINES_DATABASE_URL";
7341
- var MACHINES_STORAGE_MODE_ENV = "HASNA_MACHINES_STORAGE_MODE";
7342
- var MACHINES_STORAGE_MODE_FALLBACK_ENV = "MACHINES_STORAGE_MODE";
7343
- var STORAGE_DATABASE_ENV = [MACHINES_STORAGE_ENV, MACHINES_STORAGE_FALLBACK_ENV];
7344
- var PRIMARY_KEYS = {
7345
- agent_heartbeats: ["machine_id", "pid"],
7346
- setup_runs: ["id"],
7347
- sync_runs: ["id"]
7348
- };
7349
- function readEnv2(name) {
7350
- const value = process.env[name]?.trim();
7351
- return value || undefined;
7823
+ function detectPackageActions(machine, runner) {
7824
+ return (machine.packages || []).map((pkg, index) => {
7825
+ const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
7826
+ const check2 = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
7827
+ if (check2.exitCode !== 0) {
7828
+ throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check2));
7829
+ }
7830
+ const installed = check2.stdout.split(`
7831
+ `).some((line) => line.trim() === "installed=1");
7832
+ return {
7833
+ id: `package-${index + 1}`,
7834
+ title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
7835
+ command: packageInstallCommand(machine, pkg.name, manager),
7836
+ status: installed ? "ok" : "missing",
7837
+ kind: "package"
7838
+ };
7839
+ });
7352
7840
  }
7353
- function normalizeStorageMode(value) {
7354
- const normalized = value?.trim().toLowerCase();
7355
- if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
7356
- return normalized;
7357
- return;
7841
+ function detectFileActions(machine) {
7842
+ if ((machine.files || []).length > 0 && resolveMachineCommand(machine.id, "true").source !== "local") {
7843
+ throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
7844
+ }
7845
+ return (machine.files || []).map((file, index) => {
7846
+ const sourceExists = existsSync7(file.source);
7847
+ const targetExists = existsSync7(file.target);
7848
+ let status = "missing";
7849
+ if (sourceExists && targetExists) {
7850
+ if (file.mode === "symlink") {
7851
+ status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
7852
+ } else {
7853
+ const source = readFileSync5(file.source, "utf8");
7854
+ const target = readFileSync5(file.target, "utf8");
7855
+ status = source === target ? "ok" : "drifted";
7856
+ }
7857
+ }
7858
+ const command2 = file.mode === "symlink" ? `ln -sfn ${quote4(file.source)} ${quote4(file.target)}` : `cp ${quote4(file.source)} ${quote4(file.target)}`;
7859
+ return {
7860
+ id: `file-${index + 1}`,
7861
+ title: `${status === "ok" ? "File in sync" : "Reconcile file"} ${file.target}`,
7862
+ command: command2,
7863
+ status,
7864
+ kind: "file"
7865
+ };
7866
+ });
7358
7867
  }
7359
- function getStorageDatabaseEnvName() {
7360
- for (const name of STORAGE_DATABASE_ENV) {
7361
- if (readEnv2(name))
7362
- return name;
7868
+ function buildSyncPlan(machineId, runner = runMachineCommand) {
7869
+ const manifest = readManifest();
7870
+ const currentMachineId = getLocalMachineId();
7871
+ const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
7872
+ if (machineId && !selected) {
7873
+ throw new Error(`Machine not found in manifest: ${machineId}`);
7363
7874
  }
7364
- return null;
7365
- }
7366
- function getStorageDatabaseEnv() {
7367
- const name = getStorageDatabaseEnvName();
7368
- return name ? { name } : null;
7369
- }
7370
- function getStorageDatabaseUrl() {
7371
- const env = getStorageDatabaseEnv();
7372
- return env ? readEnv2(env.name) ?? null : null;
7373
- }
7374
- function getStorageMode() {
7375
- const mode = normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_FALLBACK_ENV));
7376
- if (mode)
7377
- return mode;
7378
- return getStorageDatabaseUrl() ? "hybrid" : "local";
7875
+ const target = selected || {
7876
+ id: currentMachineId,
7877
+ platform: "linux",
7878
+ workspacePath: `${homedir5()}/workspace`
7879
+ };
7880
+ const actions = [
7881
+ ...detectPackageActions(target, runner),
7882
+ ...detectFileActions(target)
7883
+ ];
7884
+ return {
7885
+ machineId: target.id,
7886
+ mode: "plan",
7887
+ actions,
7888
+ executed: 0
7889
+ };
7379
7890
  }
7380
- async function getStoragePg() {
7381
- const url = getStorageDatabaseUrl();
7382
- if (!url) {
7383
- throw new Error("Missing HASNA_MACHINES_DATABASE_URL or MACHINES_DATABASE_URL");
7891
+ function applyFileAction(command2) {
7892
+ const [verb, source, target] = command2.split(" ");
7893
+ if (verb === "cp" && source && target) {
7894
+ ensureParentDir(target);
7895
+ copyFileSync(source.slice(1, -1), target.slice(1, -1));
7896
+ return;
7384
7897
  }
7385
- return new PgAdapterAsync(url);
7386
- }
7387
- async function runStorageMigrations(remote) {
7388
- for (const sql of PG_MIGRATIONS)
7389
- await remote.run(sql);
7390
- }
7391
- async function storagePush(options) {
7392
- const remote = await getStoragePg();
7393
- const db = getDb();
7394
- try {
7395
- await runStorageMigrations(remote);
7396
- const results = [];
7397
- for (const table of resolveTables(options?.tables)) {
7398
- results.push(await pushTable(db, remote, table));
7898
+ if (verb === "ln" && source && target) {
7899
+ const sourcePath = command2.match(/ln -sfn '(.+)' '(.+)'/)?.[1];
7900
+ const targetPath = command2.match(/ln -sfn '(.+)' '(.+)'/)?.[2];
7901
+ if (!sourcePath || !targetPath) {
7902
+ throw new Error(`Unable to parse symlink command: ${command2}`);
7399
7903
  }
7400
- recordSyncMeta(db, "push", results);
7401
- return results;
7402
- } finally {
7403
- await remote.close();
7904
+ ensureParentDir(targetPath);
7905
+ try {
7906
+ Bun.file(targetPath).delete();
7907
+ } catch {}
7908
+ symlinkSync(sourcePath, targetPath);
7404
7909
  }
7405
7910
  }
7406
- async function storagePull(options) {
7407
- const remote = await getStoragePg();
7408
- const db = getDb();
7409
- try {
7410
- await runStorageMigrations(remote);
7411
- const results = [];
7412
- for (const table of resolveTables(options?.tables)) {
7413
- results.push(await pullTable(remote, db, table));
7911
+ function runSync(machineId, options = {}, runner = runMachineCommand) {
7912
+ const plan = buildSyncPlan(machineId, runner);
7913
+ if (!options.apply) {
7914
+ return plan;
7915
+ }
7916
+ if (!options.yes) {
7917
+ throw new Error("Sync execution requires --yes.");
7918
+ }
7919
+ let executed = 0;
7920
+ for (const action of plan.actions) {
7921
+ if (action.status === "ok")
7922
+ continue;
7923
+ if (action.kind === "file") {
7924
+ applyFileAction(action.command);
7925
+ } else {
7926
+ const result = runner(plan.machineId, action.command);
7927
+ if (result.exitCode !== 0) {
7928
+ recordSyncRun(plan.machineId, "failed", {
7929
+ executed,
7930
+ failedAction: action,
7931
+ stderr: result.stderr,
7932
+ stdout: result.stdout,
7933
+ exitCode: result.exitCode,
7934
+ source: result.source
7935
+ });
7936
+ throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
7937
+ }
7414
7938
  }
7415
- recordSyncMeta(db, "pull", results);
7416
- return results;
7417
- } finally {
7418
- await remote.close();
7939
+ executed += 1;
7419
7940
  }
7420
- }
7421
- async function storageSync(options) {
7422
- const pull = await storagePull(options);
7423
- const push = await storagePush(options);
7424
- return { pull, push };
7425
- }
7426
- function getSyncMetaAll() {
7427
- const db = getDb();
7428
- ensureSyncMetaTable(db);
7429
- return db.query("SELECT table_name, last_synced_at, direction FROM _machines_sync_meta ORDER BY table_name, direction").all();
7430
- }
7431
- function getStorageStatus() {
7432
- const activeEnv = getStorageDatabaseEnv();
7433
- return {
7434
- configured: Boolean(activeEnv),
7435
- mode: getStorageMode(),
7436
- env: STORAGE_DATABASE_ENV,
7437
- activeEnv: activeEnv?.name ?? null,
7438
- service: "machines",
7439
- tables: STORAGE_TABLES,
7440
- sync: getSyncMetaAll()
7941
+ const summary = {
7942
+ machineId: plan.machineId,
7943
+ mode: "apply",
7944
+ actions: plan.actions,
7945
+ executed
7441
7946
  };
7947
+ recordSyncRun(plan.machineId, "completed", summary);
7948
+ return summary;
7442
7949
  }
7443
- function resolveTables(tables) {
7444
- if (!tables || tables.length === 0)
7445
- return [...STORAGE_TABLES];
7446
- const allowed = new Set(STORAGE_TABLES);
7447
- const requested = tables.map((table) => table.trim()).filter(Boolean);
7448
- const invalid = requested.filter((table) => !allowed.has(table));
7449
- if (invalid.length > 0)
7450
- throw new Error(`Unknown machines storage table(s): ${invalid.join(", ")}`);
7451
- return requested;
7452
- }
7453
- async function pushTable(db, remote, table) {
7454
- const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
7455
- try {
7456
- if (!tableExists(db, table))
7457
- return result;
7458
- const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
7459
- result.rowsRead = rows.length;
7460
- if (rows.length === 0)
7461
- return result;
7462
- const remoteColumns = await getRemoteColumns(remote, table);
7463
- const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
7464
- result.rowsWritten = await upsertPg(remote, table, columns, rows);
7465
- } catch (error) {
7466
- result.errors.push(error instanceof Error ? error.message : String(error));
7467
- }
7468
- return result;
7950
+
7951
+ // src/compatibility.ts
7952
+ var DEFAULT_COMMANDS = [
7953
+ { command: "bun", required: true },
7954
+ { command: "machines", required: true }
7955
+ ];
7956
+ function defaultPackages() {
7957
+ return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
7469
7958
  }
7470
- async function pullTable(remote, db, table) {
7471
- const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
7472
- try {
7473
- if (!tableExists(db, table))
7474
- return result;
7475
- const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
7476
- result.rowsRead = rows.length;
7477
- if (rows.length === 0)
7478
- return result;
7479
- const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
7480
- result.rowsWritten = upsertSqlite(db, table, columns, rows);
7481
- } catch (error) {
7482
- result.errors.push(error instanceof Error ? error.message : String(error));
7483
- }
7484
- return result;
7959
+ function shellQuote6(value) {
7960
+ return `'${value.replace(/'/g, "'\\''")}'`;
7485
7961
  }
7486
- async function getRemoteColumns(remote, table) {
7487
- const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
7488
- return new Set(rows.map((row) => row.column_name));
7962
+ function commandId(value) {
7963
+ return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
7489
7964
  }
7490
- function filterRemoteColumns(remoteColumns, columns) {
7491
- if (remoteColumns.size === 0)
7492
- return columns;
7493
- return columns.filter((column) => remoteColumns.has(column));
7965
+ function packageCommand(name) {
7966
+ if (name === "@hasna/knowledge")
7967
+ return "knowledge";
7968
+ if (name === "@hasna/machines")
7969
+ return "machines";
7970
+ return name.split("/").pop() ?? name;
7494
7971
  }
7495
- function filterLocalColumns(db, table, columns) {
7496
- const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
7497
- const allowed = new Set(rows.map((row) => row.name));
7498
- return columns.filter((column) => allowed.has(column));
7972
+ function firstLine(value) {
7973
+ return value.trim().split(/\r?\n/).find(Boolean) ?? "";
7499
7974
  }
7500
- async function upsertPg(remote, table, columns, rows) {
7501
- if (columns.length === 0)
7502
- return 0;
7503
- const primaryKeys = PRIMARY_KEYS[table];
7504
- const columnList = columns.map(quoteIdent).join(", ");
7505
- const placeholders = columns.map(() => "?").join(", ");
7506
- const keyList = primaryKeys.map(quoteIdent).join(", ");
7507
- const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
7508
- const fallbackKey = primaryKeys[0];
7509
- const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
7510
- for (const row of rows) {
7511
- await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
7512
- ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column])));
7513
- }
7514
- return rows.length;
7975
+ function extractVersion(value) {
7976
+ const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
7977
+ return match?.[0] ?? null;
7515
7978
  }
7516
- function upsertSqlite(db, table, columns, rows) {
7517
- if (columns.length === 0)
7518
- return 0;
7519
- const primaryKeys = PRIMARY_KEYS[table];
7520
- const columnList = columns.map(quoteIdent).join(", ");
7521
- const placeholders = columns.map(() => "?").join(", ");
7522
- const keyList = primaryKeys.map(quoteIdent).join(", ");
7523
- const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
7524
- const fallbackKey = primaryKeys[0];
7525
- const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
7526
- const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
7527
- ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
7528
- const insert = db.transaction((batch) => {
7529
- for (const row of batch)
7530
- statement.run(...columns.map((column) => coerceForSqlite(row[column])));
7531
- });
7532
- insert(rows);
7533
- return rows.length;
7979
+ function statusFor(required, ok) {
7980
+ if (ok)
7981
+ return "ok";
7982
+ return required === false ? "warn" : "fail";
7534
7983
  }
7535
- function recordSyncMeta(db, direction, results) {
7536
- ensureSyncMetaTable(db);
7537
- const now = new Date().toISOString();
7538
- const statement = db.query(`
7539
- INSERT INTO _machines_sync_meta (table_name, last_synced_at, direction)
7540
- VALUES (?, ?, ?)
7541
- ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
7542
- `);
7543
- for (const result of results) {
7544
- if (result.errors.length > 0)
7984
+ function makeCheck2(input) {
7985
+ return {
7986
+ id: input.id,
7987
+ kind: input.kind,
7988
+ status: input.status,
7989
+ target: input.target,
7990
+ expected: input.expected ?? null,
7991
+ actual: input.actual ?? null,
7992
+ detail: input.detail,
7993
+ source: input.source
7994
+ };
7995
+ }
7996
+ function parseKeyValue(stdout) {
7997
+ const result = {};
7998
+ for (const line of stdout.split(/\r?\n/)) {
7999
+ const idx = line.indexOf("=");
8000
+ if (idx <= 0)
7545
8001
  continue;
7546
- statement.run(result.table, now, direction);
8002
+ result[line.slice(0, idx)] = line.slice(idx + 1);
7547
8003
  }
8004
+ return result;
7548
8005
  }
7549
- function ensureSyncMetaTable(db) {
7550
- db.exec(`
7551
- CREATE TABLE IF NOT EXISTS _machines_sync_meta (
7552
- table_name TEXT NOT NULL,
7553
- last_synced_at TEXT,
7554
- direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
7555
- PRIMARY KEY (table_name, direction)
7556
- )
7557
- `);
8006
+ function defaultRunner2(machineId, command2) {
8007
+ return runMachineCommand(machineId, command2);
7558
8008
  }
7559
- function tableExists(db, table) {
7560
- const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
7561
- return Boolean(row);
8009
+ function inspectCommand(machineId, spec, runner) {
8010
+ const command2 = shellQuote6(spec.command);
8011
+ const versionArgs = spec.versionArgs ?? "--version";
8012
+ const script = [
8013
+ `cmd=${command2}`,
8014
+ 'path="$(command -v "$cmd" 2>/dev/null || true)"',
8015
+ 'printf "path=%s\\n" "$path"',
8016
+ 'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
8017
+ ].join("; ");
8018
+ const result = runner(machineId, script);
8019
+ const parsed = parseKeyValue(result.stdout);
8020
+ return {
8021
+ path: parsed.path || null,
8022
+ version: parsed.version ? firstLine(parsed.version) : null,
8023
+ exitCode: result.exitCode,
8024
+ source: result.source,
8025
+ stderr: result.stderr
8026
+ };
7562
8027
  }
7563
- function quoteIdent(identifier) {
7564
- return `"${identifier.replace(/"/g, '""')}"`;
8028
+ function fieldCommand(field) {
8029
+ const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
8030
+ return [
8031
+ `if command -v bun >/dev/null 2>&1; then bun -e "const p=JSON.parse(await Bun.file(process.argv[1]).text()); console.log(p.${field} ?? '')" "$pkg" 2>/dev/null`,
8032
+ `elif command -v node >/dev/null 2>&1; then node -e "const fs=require('fs'); const p=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(p.${field} || '')" "$pkg" 2>/dev/null`,
8033
+ `else sed -n '${regex}' "$pkg" | head -n 1`,
8034
+ "fi"
8035
+ ].join("; ");
7565
8036
  }
7566
- function coerceForPg(value) {
7567
- if (value === undefined || value === null)
7568
- return null;
7569
- if (value instanceof Date)
7570
- return value.toISOString();
7571
- if (Buffer.isBuffer(value) || value instanceof Uint8Array)
7572
- return value;
7573
- if (typeof value === "object")
7574
- return JSON.stringify(value);
7575
- return value;
8037
+ function inspectWorkspace(machineId, spec, runner) {
8038
+ const script = [
8039
+ `path=${shellQuote6(spec.path)}`,
8040
+ 'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
8041
+ 'pkg="$path/package.json"',
8042
+ 'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
8043
+ `if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
8044
+ ].join("; ");
8045
+ const result = runner(machineId, script);
8046
+ const parsed = parseKeyValue(result.stdout);
8047
+ return {
8048
+ exists: parsed.exists === "yes",
8049
+ packageJson: parsed.package_json === "yes",
8050
+ packageName: parsed.package_name || null,
8051
+ version: parsed.version || null,
8052
+ source: result.source,
8053
+ stderr: result.stderr
8054
+ };
7576
8055
  }
7577
- function coerceForSqlite(value) {
7578
- if (value === undefined || value === null)
7579
- return null;
7580
- if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
7581
- return value;
7582
- if (value instanceof Date)
7583
- return value.toISOString();
7584
- if (Buffer.isBuffer(value) || value instanceof Uint8Array)
7585
- return value;
7586
- if (typeof value === "object")
7587
- return JSON.stringify(value);
7588
- return String(value);
8056
+ function commandCheck(machineId, spec, runner) {
8057
+ const inspection = inspectCommand(machineId, spec, runner);
8058
+ const found = Boolean(inspection.path);
8059
+ const checks = [
8060
+ makeCheck2({
8061
+ id: `command:${commandId(spec.command)}:path`,
8062
+ kind: "command",
8063
+ status: statusFor(spec.required, found),
8064
+ target: spec.command,
8065
+ expected: "available",
8066
+ actual: inspection.path ?? "missing",
8067
+ detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
8068
+ source: inspection.source
8069
+ })
8070
+ ];
8071
+ if (spec.expectedVersion) {
8072
+ const actualVersion = extractVersion(inspection.version ?? "");
8073
+ checks.push(makeCheck2({
8074
+ id: `command:${commandId(spec.command)}:version`,
8075
+ kind: "command",
8076
+ status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
8077
+ target: spec.command,
8078
+ expected: spec.expectedVersion,
8079
+ actual: actualVersion ?? inspection.version ?? "missing",
8080
+ detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
8081
+ source: inspection.source
8082
+ }));
8083
+ }
8084
+ return checks;
8085
+ }
8086
+ function packageCheck(machineId, spec, runner) {
8087
+ const command2 = spec.command ?? packageCommand(spec.name);
8088
+ const inspection = inspectCommand(machineId, { command: command2, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
8089
+ const found = Boolean(inspection.path);
8090
+ const checks = [
8091
+ makeCheck2({
8092
+ id: `package:${commandId(spec.name)}:command`,
8093
+ kind: "package",
8094
+ status: statusFor(spec.required, found),
8095
+ target: spec.name,
8096
+ expected: command2,
8097
+ actual: inspection.path ?? "missing",
8098
+ detail: found ? `${command2} found at ${inspection.path}` : `${command2} command missing`,
8099
+ source: inspection.source
8100
+ })
8101
+ ];
8102
+ if (spec.expectedVersion) {
8103
+ const actualVersion = extractVersion(inspection.version ?? "");
8104
+ checks.push(makeCheck2({
8105
+ id: `package:${commandId(spec.name)}:version`,
8106
+ kind: "package",
8107
+ status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
8108
+ target: spec.name,
8109
+ expected: spec.expectedVersion,
8110
+ actual: actualVersion ?? inspection.version ?? "missing",
8111
+ detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
8112
+ source: inspection.source
8113
+ }));
8114
+ }
8115
+ return checks;
8116
+ }
8117
+ function workspaceCheck(machineId, spec, runner) {
8118
+ const inspection = inspectWorkspace(machineId, spec, runner);
8119
+ const target = spec.label ?? spec.path;
8120
+ const checks = [
8121
+ makeCheck2({
8122
+ id: `workspace:${commandId(target)}:path`,
8123
+ kind: "workspace",
8124
+ status: statusFor(spec.required, inspection.exists),
8125
+ target,
8126
+ expected: spec.path,
8127
+ actual: inspection.exists ? "exists" : "missing",
8128
+ detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
8129
+ source: inspection.source
8130
+ })
8131
+ ];
8132
+ if (spec.expectedPackageName) {
8133
+ checks.push(makeCheck2({
8134
+ id: `workspace:${commandId(target)}:package-name`,
8135
+ kind: "workspace",
8136
+ status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
8137
+ target,
8138
+ expected: spec.expectedPackageName,
8139
+ actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
8140
+ detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
8141
+ source: inspection.source
8142
+ }));
8143
+ }
8144
+ if (spec.expectedVersion) {
8145
+ checks.push(makeCheck2({
8146
+ id: `workspace:${commandId(target)}:version`,
8147
+ kind: "workspace",
8148
+ status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
8149
+ target,
8150
+ expected: spec.expectedVersion,
8151
+ actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
8152
+ detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
8153
+ source: inspection.source
8154
+ }));
8155
+ }
8156
+ return checks;
8157
+ }
8158
+ function checkMachineCompatibility(options = {}) {
8159
+ const machineId = options.machineId ?? getLocalMachineId();
8160
+ const runner = options.runner ?? defaultRunner2;
8161
+ const commands = options.commands ?? DEFAULT_COMMANDS;
8162
+ const packages = options.packages ?? defaultPackages();
8163
+ const workspaces = options.workspaces ?? [];
8164
+ const checks = [];
8165
+ for (const spec of commands)
8166
+ checks.push(...commandCheck(machineId, spec, runner));
8167
+ for (const spec of packages)
8168
+ checks.push(...packageCheck(machineId, spec, runner));
8169
+ for (const spec of workspaces)
8170
+ checks.push(...workspaceCheck(machineId, spec, runner));
8171
+ const summary = {
8172
+ ok: checks.filter((check2) => check2.status === "ok").length,
8173
+ warn: checks.filter((check2) => check2.status === "warn").length,
8174
+ fail: checks.filter((check2) => check2.status === "fail").length
8175
+ };
8176
+ return {
8177
+ schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8178
+ package: {
8179
+ name: MACHINES_PACKAGE_NAME,
8180
+ version: getPackageVersion()
8181
+ },
8182
+ capabilities: getMachinesConsumerCapabilities(),
8183
+ ok: summary.fail === 0,
8184
+ machine_id: machineId,
8185
+ source: checks[0]?.source ?? "local",
8186
+ generated_at: (options.now ?? new Date).toISOString(),
8187
+ checks,
8188
+ summary
8189
+ };
7589
8190
  }
7590
8191
  // src/mcp/server.ts
7591
8192
  function buildServer(version = getPackageVersion()) {
7592
8193
  return createMcpServer(version);
7593
8194
  }
8195
+ function privateMetadataAllowed(requested) {
8196
+ return requested === true && isPrivateOutputEnabled();
8197
+ }
8198
+ function privateOutputWarnings(requested, allowed) {
8199
+ return requested === true && !allowed ? [PRIVATE_OUTPUT_DENIED_WARNING] : [];
8200
+ }
8201
+ function appendWarnings(payload, warnings) {
8202
+ if (warnings.length === 0)
8203
+ return payload;
8204
+ return { ...payload, warnings: [...payload.warnings ?? [], ...warnings] };
8205
+ }
7594
8206
  function createMcpServer(version) {
7595
8207
  const server = new McpServer({ name: "machines", version });
7596
8208
  const events = new EventsClient2;
@@ -7617,16 +8229,69 @@ function createMcpServer(version) {
7617
8229
  }));
7618
8230
  server.tool("machines_manifest_get", "Read a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestGet(machine_id), null, 2) }] }));
7619
8231
  server.tool("machines_manifest_remove", "Remove a single machine from the fleet manifest.", { machine_id: exports_external.string().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(manifestRemove(machine_id), null, 2) }] }));
7620
- server.tool("machines_agent_status", "List current machine agent heartbeats.", {}, async () => ({
7621
- content: [{ type: "text", text: JSON.stringify(getAgentStatus(), null, 2) }]
8232
+ server.tool("machines_agent_status", "List current machine agent heartbeats.", { private_metadata: exports_external.boolean().optional().describe("Include private heartbeat metadata") }, async ({ private_metadata }) => {
8233
+ const privateMetadata = privateMetadataAllowed(private_metadata);
8234
+ const warnings = privateOutputWarnings(private_metadata, privateMetadata);
8235
+ const agents = getAgentStatus(undefined, { privateMetadata });
8236
+ return {
8237
+ content: [{ type: "text", text: JSON.stringify(warnings.length > 0 ? { agents, warnings } : agents, null, 2) }]
8238
+ };
8239
+ });
8240
+ server.tool("machines_daemon_status", "List fleet daemon heartbeat status rows.", { private_metadata: exports_external.boolean().optional().describe("Include private heartbeat metadata") }, async ({ private_metadata }) => {
8241
+ const privateMetadata = privateMetadataAllowed(private_metadata);
8242
+ const warnings = privateOutputWarnings(private_metadata, privateMetadata);
8243
+ return {
8244
+ content: [{
8245
+ type: "text",
8246
+ text: JSON.stringify({
8247
+ generated_at: new Date().toISOString(),
8248
+ agents: getAgentStatus(undefined, { privateMetadata }),
8249
+ ...warnings.length > 0 ? { warnings } : {}
8250
+ }, null, 2)
8251
+ }]
8252
+ };
8253
+ });
8254
+ server.tool("machines_daemon_service_plan", "Plan launchd/systemd lifecycle commands for the machines-agent daemon.", {
8255
+ action: exports_external.enum(["install", "uninstall", "restart", "status", "logs"]).describe("Daemon lifecycle action"),
8256
+ platform: exports_external.enum(["macos", "linux"]).optional().describe("Target service platform"),
8257
+ mode: exports_external.enum(["user", "system"]).optional().describe("Service mode"),
8258
+ service_name: exports_external.string().optional().describe("Service name/label"),
8259
+ executable: exports_external.string().optional().describe("machines-agent executable path"),
8260
+ interval_ms: exports_external.number().optional().describe("Heartbeat interval in milliseconds"),
8261
+ storage_push: exports_external.boolean().optional().describe("Configure heartbeat storage push"),
8262
+ doctor_summary: exports_external.boolean().optional().describe("Configure lightweight doctor summaries in heartbeat metadata"),
8263
+ private_metadata: exports_external.boolean().optional().describe("Opt in to private heartbeat metadata"),
8264
+ env: exports_external.array(exports_external.string()).optional().describe("Environment variable names to include as placeholders")
8265
+ }, async ({ action, platform: platform5, mode, service_name, executable, interval_ms, storage_push, doctor_summary, private_metadata, env }) => ({
8266
+ content: [{
8267
+ type: "text",
8268
+ text: JSON.stringify(buildDaemonServicePlan({
8269
+ action,
8270
+ platform: platform5,
8271
+ mode,
8272
+ serviceName: service_name,
8273
+ executable,
8274
+ intervalMs: interval_ms,
8275
+ storagePush: storage_push,
8276
+ doctorSummary: doctor_summary,
8277
+ privateMetadata: private_metadata,
8278
+ env
8279
+ }), null, 2)
8280
+ }]
7622
8281
  }));
7623
8282
  server.tool("machines_setup_preview", "Preview setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSetupPlan(machine_id), null, 2) }] }));
7624
8283
  server.tool("machines_setup_apply", "Execute setup actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runSetup(machine_id, { apply: true, yes }), null, 2) }] }));
7625
8284
  server.tool("machines_sync_preview", "Preview sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildSyncPlan(machine_id), null, 2) }] }));
7626
8285
  server.tool("machines_sync_apply", "Execute sync actions for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runSync(machine_id, { apply: true, yes }), null, 2) }] }));
7627
- server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", { include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json") }, async ({ include_tailscale }) => ({
7628
- content: [{ type: "text", text: JSON.stringify(discoverMachineTopology({ includeTailscale: include_tailscale !== false }), null, 2) }]
7629
- }));
8286
+ server.tool("machines_topology", "Discover local, manifest, heartbeat, SSH, and Tailscale machine topology.", {
8287
+ include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
8288
+ private_metadata: exports_external.boolean().optional().describe("Include private host/network route fields")
8289
+ }, async ({ include_tailscale, private_metadata }) => {
8290
+ const privateMetadata = privateMetadataAllowed(private_metadata);
8291
+ const warnings = privateOutputWarnings(private_metadata, privateMetadata);
8292
+ const topology = redactTopologyForOutput(discoverMachineTopology({ includeTailscale: include_tailscale !== false }), { privateMetadata });
8293
+ return { content: [{ type: "text", text: JSON.stringify(appendWarnings(topology, warnings), null, 2) }] };
8294
+ });
7630
8295
  server.tool("machines_compatibility", "Check remote package, command, and workspace compatibility for open-* consumers.", {
7631
8296
  machine_id: exports_external.string().optional().describe("Machine identifier"),
7632
8297
  commands: exports_external.array(exports_external.object({
@@ -7678,9 +8343,16 @@ function createMcpServer(version) {
7678
8343
  }));
7679
8344
  server.tool("machines_install_tailscale_preview", "Preview Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier") }, async ({ machine_id }) => ({ content: [{ type: "text", text: JSON.stringify(buildTailscaleInstallPlan(machine_id), null, 2) }] }));
7680
8345
  server.tool("machines_install_tailscale_apply", "Execute Tailscale install steps for a machine.", { machine_id: exports_external.string().optional().describe("Machine identifier"), yes: exports_external.boolean().describe("Confirmation flag for execution") }, async ({ machine_id, yes }) => ({ content: [{ type: "text", text: JSON.stringify(runTailscaleInstall(machine_id, { apply: true, yes }), null, 2) }] }));
7681
- server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", { machine_id: exports_external.string().describe("Machine identifier"), include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json") }, async ({ machine_id, include_tailscale }) => ({
7682
- content: [{ type: "text", text: JSON.stringify(resolveMachineRoute(machine_id, { includeTailscale: include_tailscale !== false }), null, 2) }]
7683
- }));
8346
+ server.tool("machines_route_resolve", "Resolve the best route for a machine using manifest, heartbeat, SSH, LAN, and Tailscale topology.", {
8347
+ machine_id: exports_external.string().describe("Machine identifier"),
8348
+ include_tailscale: exports_external.boolean().optional().describe("Whether to probe tailscale status --json"),
8349
+ private_metadata: exports_external.boolean().optional().describe("Include private route targets")
8350
+ }, async ({ machine_id, include_tailscale, private_metadata }) => {
8351
+ const privateMetadata = privateMetadataAllowed(private_metadata);
8352
+ const warnings = privateOutputWarnings(private_metadata, privateMetadata);
8353
+ const route = redactRouteForOutput(resolveMachineRoute(machine_id, { includeTailscale: include_tailscale !== false }), { privateMetadata });
8354
+ return { content: [{ type: "text", text: JSON.stringify(appendWarnings(route, warnings), null, 2) }] };
8355
+ });
7684
8356
  server.tool("machines_workspace_resolve", "Resolve sync-safe repo and open-files roots for an open-* consumer.", {
7685
8357
  machine_id: exports_external.string().describe("Machine identifier"),
7686
8358
  project_id: exports_external.string().describe("Canonical project id"),