@hasna/machines 0.0.37 → 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 = {}) {
@@ -6203,8 +6656,8 @@ ${message}
6203
6656
  };
6204
6657
  }
6205
6658
  if (hasCommand2("mail")) {
6206
- const command = `printf %s ${shellQuote5(message)} | mail -s ${shellQuote5(subject)} ${shellQuote5(channel.target)}`;
6207
- 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], {
6208
6661
  stdout: "pipe",
6209
6662
  stderr: "pipe",
6210
6663
  env: process.env
@@ -6428,8 +6881,8 @@ function listPorts(machineId) {
6428
6881
  const targetMachineId = machineId || getLocalMachineId();
6429
6882
  const isLocal = targetMachineId === getLocalMachineId();
6430
6883
  const localCommand = "if command -v ss >/dev/null 2>&1; then ss -ltnpH; else lsof -nP -iTCP -sTCP:LISTEN; fi";
6431
- const command = isLocal ? localCommand : buildSshCommand(targetMachineId, localCommand);
6432
- 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" });
6433
6886
  if (result.status !== 0) {
6434
6887
  throw new Error(result.stderr || `Failed to list ports for ${targetMachineId}`);
6435
6888
  }
@@ -6443,1160 +6896,1313 @@ function listPorts(machineId) {
6443
6896
  // src/commands/serve.ts
6444
6897
  import { EventsClient, sanitizeChannelsForOutput } from "@hasna/events";
6445
6898
 
6446
- // src/commands/manifest.ts
6447
- function manifestList() {
6448
- return readManifest();
6449
- }
6450
- function manifestAdd(machine) {
6451
- const validatedMachine = machineSchema.parse(machine);
6452
- const manifest = readManifest();
6453
- const nextMachines = manifest.machines.filter((entry) => entry.id !== validatedMachine.id);
6454
- nextMachines.push(validatedMachine);
6455
- const nextManifest = { ...manifest, machines: nextMachines };
6456
- writeManifest(nextManifest);
6457
- return nextManifest;
6458
- }
6459
- function manifestBootstrapCurrentMachine() {
6460
- return manifestAdd(detectCurrentMachineManifest());
6461
- }
6462
- function manifestGet(machineId) {
6463
- 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}`);
6464
6965
  }
6465
- function manifestRemove(machineId) {
6466
- const manifest = readManifest();
6467
- const nextManifest = {
6468
- ...manifest,
6469
- machines: manifest.machines.filter((machine) => machine.id !== machineId)
6470
- };
6471
- writeManifest(nextManifest);
6472
- 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);
6473
6969
  }
6474
- function manifestValidate() {
6475
- 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;
6476
6985
  }
6477
6986
 
6478
- // src/commands/status.ts
6479
- function getStatus() {
6480
- const manifest = readManifest();
6481
- const heartbeats = listHeartbeats();
6482
- const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
6483
- const machineIds = new Set([
6484
- ...manifest.machines.map((machine) => machine.id),
6485
- ...heartbeats.map((heartbeat) => heartbeat.machine_id)
6486
- ]);
6487
- return {
6488
- machineId: getLocalMachineId(),
6489
- manifestPath: getManifestPath(),
6490
- dbPath: getDbPath(),
6491
- notificationsPath: getNotificationsPath(),
6492
- manifestMachineCount: manifest.machines.length,
6493
- heartbeatCount: heartbeats.length,
6494
- machines: [...machineIds].sort().map((machineId) => {
6495
- const declared = manifest.machines.find((machine) => machine.id === machineId);
6496
- const heartbeat = heartbeatByMachine.get(machineId);
6497
- return {
6498
- machineId,
6499
- platform: declared?.platform,
6500
- manifestDeclared: Boolean(declared),
6501
- heartbeatStatus: heartbeat?.status || "unknown",
6502
- lastHeartbeatAt: heartbeat?.updated_at
6503
- };
6504
- }),
6505
- recentSetupRuns: countRuns("setup_runs"),
6506
- recentSyncRuns: countRuns("sync_runs")
6507
- };
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
+ }
6508
7003
  }
6509
7004
 
6510
- // src/commands/self-test.ts
6511
- function check(id, status, summary, detail) {
6512
- 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;
6513
7024
  }
6514
- function runSelfTest() {
6515
- const version = getPackageVersion();
6516
- const status = getStatus();
6517
- const doctor = runDoctor();
6518
- const serveInfo = getServeInfo();
6519
- const html = renderDashboardHtml();
6520
- const notifications = listNotificationChannels();
6521
- const apps = listApps(status.machineId);
6522
- const appsDiff = diffApps(status.machineId);
6523
- const cliPlan = buildClaudeInstallPlan(status.machineId);
6524
- return {
6525
- machineId: getLocalMachineId(),
6526
- checks: [
6527
- check("package-version", version === "0.0.0" ? "fail" : "ok", "Package version resolves", version),
6528
- check("status", "ok", "Status loads", JSON.stringify({ machines: status.manifestMachineCount, heartbeats: status.heartbeatCount })),
6529
- check("doctor", doctor.checks.some((entry) => entry.status === "fail") ? "warn" : "ok", "Doctor completed", `${doctor.checks.length} checks`),
6530
- check("serve-info", "ok", "Dashboard info renders", `${serveInfo.url} routes=${serveInfo.routes.length}`),
6531
- check("dashboard-html", html.includes("Machines Dashboard") ? "ok" : "fail", "Dashboard HTML renders", html.slice(0, 80)),
6532
- check("notifications", "ok", "Notifications config loads", `${notifications.channels.length} channels`),
6533
- check("apps", "ok", "Apps manifest loads", `${apps.apps.length} apps`),
6534
- check("apps-diff", appsDiff.missing.length === 0 ? "ok" : "warn", "Apps diff computed", `missing=${appsDiff.missing.length} installed=${appsDiff.installed.length}`),
6535
- check("install-claude-plan", cliPlan.steps.length > 0 ? "ok" : "warn", "Install plan renders", `${cliPlan.steps.length} steps`)
6536
- ]
6537
- };
7025
+ function normalizeStorageMode(value) {
7026
+ const normalized = value?.trim().toLowerCase();
7027
+ if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
7028
+ return normalized;
7029
+ return;
6538
7030
  }
6539
-
6540
- // src/commands/serve.ts
6541
- function escapeHtml(value) {
6542
- 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;
6543
7037
  }
6544
- function getServeInfo(options = {}) {
6545
- const host = options.host || "0.0.0.0";
6546
- 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();
6547
7105
  return {
6548
- host,
6549
- port,
6550
- url: `http://${host}:${port}`,
6551
- routes: [
6552
- "/",
6553
- "/health",
6554
- "/api/status",
6555
- "/api/manifest",
6556
- "/api/notifications",
6557
- "/api/webhooks",
6558
- "/api/events",
6559
- "/api/doctor",
6560
- "/api/self-test",
6561
- "/api/apps/status",
6562
- "/api/apps/diff",
6563
- "/api/install-claude/status",
6564
- "/api/install-claude/diff",
6565
- "/api/notifications/test",
6566
- "/api/webhooks/test"
6567
- ]
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()
6568
7113
  };
6569
7114
  }
6570
- function renderDashboardHtml() {
6571
- const status = getStatus();
6572
- const manifest = manifestList();
6573
- const notifications = listNotificationChannels();
6574
- const doctor = runDoctor();
6575
- return `<!doctype html>
6576
- <html lang="en">
6577
- <head>
6578
- <meta charset="utf-8" />
6579
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6580
- <title>Machines Dashboard</title>
6581
- <style>
6582
- :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
6583
- body { margin: 0; background: #0b1020; color: #e5ecff; }
6584
- main { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; }
6585
- h1, h2 { margin: 0 0 16px; }
6586
- .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
6587
- .card { background: #121933; border: 1px solid #243057; border-radius: 16px; padding: 20px; }
6588
- .stat { font-size: 32px; font-weight: 700; margin-top: 8px; }
6589
- table { width: 100%; border-collapse: collapse; }
6590
- th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #243057; vertical-align: top; }
6591
- code { color: #9ed0ff; }
6592
- .badge { display: inline-block; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
6593
- .online, .ok { background: #12351f; color: #74f0a7; }
6594
- .offline, .fail { background: #3b1a1a; color: #ff8c8c; }
6595
- .unknown, .warn { background: #2f2b16; color: #ffd76a; }
6596
- ul { margin: 8px 0 0; padding-left: 18px; }
6597
- .muted { color: #9fb0d9; }
6598
- .refresh { font-size: 12px; color: #6b7fa3; margin-left: auto; }
6599
- .updated { transition: opacity 0.3s; }
6600
- </style>
6601
- </head>
6602
- <body>
6603
- <main>
6604
- <h1>Machines Dashboard <span class="refresh" id="last-updated"></span></h1>
6605
- <div class="grid">
6606
- <section class="card"><div>Manifest machines</div><div class="stat">${status.manifestMachineCount}</div></section>
6607
- <section class="card"><div>Heartbeats</div><div class="stat">${status.heartbeatCount}</div></section>
6608
- <section class="card"><div>Notification channels</div><div class="stat">${notifications.channels.length}</div></section>
6609
- <section class="card"><div>Doctor warnings</div><div class="stat">${doctor.checks.filter((entry) => entry.status !== "ok").length}</div></section>
6610
- </div>
6611
-
6612
- <section class="card" style="margin-top:16px">
6613
- <h2>Machines</h2>
6614
- <table>
6615
- <thead><tr><th>ID</th><th>Platform</th><th>Status</th><th>Last heartbeat</th></tr></thead>
6616
- <tbody>
6617
- ${status.machines.map((machine) => `<tr>
6618
- <td><code>${escapeHtml(machine.machineId)}</code></td>
6619
- <td>${escapeHtml(machine.platform || "unknown")}</td>
6620
- <td><span class="badge ${escapeHtml(machine.heartbeatStatus)}">${escapeHtml(machine.heartbeatStatus)}</span></td>
6621
- <td>${escapeHtml(machine.lastHeartbeatAt || "\u2014")}</td>
6622
- </tr>`).join("")}
6623
- </tbody>
6624
- </table>
6625
- </section>
6626
-
6627
- <section class="card" style="margin-top:16px">
6628
- <h2>Doctor</h2>
6629
- <table>
6630
- <thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
6631
- <tbody id="doctor-tbody">
6632
- ${doctor.checks.map((entry) => `<tr>
6633
- <td>${escapeHtml(entry.summary)}</td>
6634
- <td><span class="badge ${escapeHtml(entry.status)}">${escapeHtml(entry.status)}</span></td>
6635
- <td class="muted">${escapeHtml(entry.detail)}</td>
6636
- </tr>`).join("")}
6637
- </tbody>
6638
- </table>
6639
- </section>
6640
-
6641
- <section class="card" style="margin-top:16px">
6642
- <h2>Apps</h2>
6643
- <p class="muted">Use <code>/api/apps/status</code> for the full app inventory payload.</p>
6644
- </section>
6645
-
6646
- <section class="card" style="margin-top:16px">
6647
- <h2>AI CLIs</h2>
6648
- <p class="muted">Use <code>/api/install-claude/status</code> for the full CLI inventory payload.</p>
6649
- </section>
6650
-
6651
- <section class="card" style="margin-top:16px">
6652
- <h2>Self Test</h2>
6653
- <p class="muted">Use <code>/api/self-test</code> for the full smoke-check payload.</p>
6654
- </section>
6655
-
6656
- <section class="card" style="margin-top:16px">
6657
- <h2>Manifest</h2>
6658
- <pre id="manifest-json">${escapeHtml(JSON.stringify(manifest, null, 2))}</pre>
6659
- </section>
6660
- </main>
6661
- <script>
6662
- // Auto-refresh dashboard data every 15s
6663
- const REFRESH_INTERVAL = 15000;
6664
- async function refreshData() {
6665
- try {
6666
- const [statusRes, doctorRes] = await Promise.all([
6667
- fetch("/api/status"),
6668
- fetch("/api/doctor"),
6669
- ]);
6670
- const status = await statusRes.json();
6671
- const doctor = await doctorRes.json();
6672
-
6673
- // Update stat cards
6674
- const stats = document.querySelectorAll(".stat");
6675
- if (stats[0]) stats[0].textContent = status.manifestMachineCount;
6676
- if (stats[1]) stats[1].textContent = status.heartbeatCount;
6677
-
6678
- // Update machine table
6679
- const tbody = document.querySelector("tbody");
6680
- if (tbody && status.machines) {
6681
- tbody.innerHTML = status.machines
6682
- .map((m) =>
6683
- "<tr>" +
6684
- "<td><code>" + m.machineId + "</code></td>" +
6685
- "<td>" + (m.platform || "unknown") + "</td>" +
6686
- '<td><span class="badge ' + m.heartbeatStatus + '">' + m.heartbeatStatus + '</span></td>' +
6687
- "<td>" + (m.lastHeartbeatAt || "\\u2014") + "</td>" +
6688
- "</tr>"
6689
- )
6690
- .join("");
6691
- }
6692
-
6693
- // Update doctor table
6694
- const doctorTbody = document.getElementById("doctor-tbody");
6695
- if (doctorTbody && doctor.checks) {
6696
- doctorTbody.innerHTML = doctor.checks
6697
- .map((c) =>
6698
- "<tr>" +
6699
- "<td>" + c.summary + "</td>" +
6700
- '<td><span class="badge ' + c.status + '">' + c.status + '</span></td>' +
6701
- '<td class="muted">' + c.detail + "</td>" +
6702
- "</tr>"
6703
- )
6704
- .join("");
6705
- }
6706
-
6707
- // Update timestamp
6708
- document.getElementById("last-updated").textContent =
6709
- "updated " + new Date().toLocaleTimeString();
6710
- } catch (e) {
6711
- // Silently ignore fetch errors during page unload
6712
- }
6713
- }
6714
- document.getElementById("last-updated").textContent =
6715
- "updated " + new Date().toLocaleTimeString();
6716
- setInterval(refreshData, REFRESH_INTERVAL);
6717
- </script>
6718
- </body>
6719
- </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;
6720
7124
  }
6721
-
6722
- // src/commands/setup.ts
6723
- import { homedir as homedir4 } from "os";
6724
- function quote3(value) {
6725
- 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;
6726
7141
  }
6727
- function buildBaseSteps(machine) {
6728
- const steps = [
6729
- {
6730
- id: "workspace",
6731
- title: "Ensure workspace directory exists",
6732
- command: `mkdir -p ${quote3(machine.workspacePath)}`,
6733
- manager: "shell"
6734
- },
6735
- {
6736
- id: "bun",
6737
- title: "Install Bun if missing",
6738
- command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
6739
- manager: "shell"
6740
- }
6741
- ];
6742
- if (machine.platform === "linux") {
6743
- steps.push({
6744
- id: "apt-base",
6745
- title: "Install core Linux tooling",
6746
- command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
6747
- manager: "apt",
6748
- privileged: true
6749
- });
6750
- steps.push({
6751
- id: "linux-update-downloads",
6752
- title: "Enable Linux package list refresh and download-only upgrades",
6753
- 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`,
6754
- manager: "apt",
6755
- privileged: true
6756
- });
6757
- } else if (machine.platform === "macos") {
6758
- steps.push({
6759
- id: "brew-base",
6760
- title: "Install Homebrew if missing",
6761
- command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
6762
- manager: "brew"
6763
- });
6764
- steps.push({
6765
- id: "brew-core",
6766
- title: "Install core macOS tooling",
6767
- command: "brew install git coreutils",
6768
- manager: "brew"
6769
- });
6770
- steps.push({
6771
- id: "macos-update-downloads",
6772
- title: "Enable macOS update checks and downloads without automatic install",
6773
- 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",
6774
- manager: "custom",
6775
- privileged: true
6776
- });
6777
- steps.push({
6778
- id: "macos-management-readiness",
6779
- title: "Report Apple management readiness without enrolling devices",
6780
- command: "profiles status -type enrollment 2>/dev/null || true",
6781
- manager: "custom"
6782
- });
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));
6783
7155
  }
6784
- steps.push({
6785
- id: "github-app-auth-readiness",
6786
- title: "Check GitHub CLI/App auth readiness without printing credentials",
6787
- command: "command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 || true",
6788
- 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])));
6789
7203
  });
6790
- return steps;
7204
+ insert(rows);
7205
+ return rows.length;
6791
7206
  }
6792
- function buildPackageSteps(machine) {
6793
- return (machine.packages || []).map((pkg, index) => {
6794
- const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
6795
- let command = pkg.name;
6796
- if (manager === "bun") {
6797
- command = `bun install -g ${quote3(pkg.name)}`;
6798
- } else if (manager === "brew") {
6799
- command = `brew install ${quote3(pkg.name)}`;
6800
- } else if (manager === "apt") {
6801
- command = `sudo apt-get install -y ${quote3(pkg.name)}`;
6802
- }
6803
- return {
6804
- id: `package-${index + 1}`,
6805
- title: `Install package ${pkg.name}`,
6806
- command,
6807
- manager,
6808
- privileged: manager === "apt"
6809
- };
6810
- });
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
+ }
6811
7220
  }
6812
- function buildSetupPlan(machineId) {
6813
- const manifest = readManifest();
6814
- const currentMachineId = getLocalMachineId();
6815
- const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
6816
- if (machineId && !selected) {
6817
- 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;
6818
7272
  }
6819
- const target = selected || {
6820
- id: currentMachineId,
6821
- platform: "linux",
6822
- workspacePath: `${homedir4()}/workspace`
6823
- };
6824
- 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);
6825
7278
  return {
6826
- machineId: target.id,
6827
- mode: "plan",
6828
- steps,
6829
- 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)
6830
7296
  };
6831
7297
  }
6832
- function runSetup(machineId, options = {}, runner = runMachineCommand) {
6833
- const plan = buildSetupPlan(machineId);
6834
- if (!options.apply) {
6835
- return plan;
6836
- }
6837
- if (!options.yes) {
6838
- throw new Error("Setup execution requires --yes.");
6839
- }
6840
- let executed = 0;
6841
- for (const step of plan.steps) {
6842
- const result = runner(plan.machineId, step.command);
6843
- if (result.exitCode !== 0) {
6844
- recordSetupRun(plan.machineId, "failed", {
6845
- executed,
6846
- failedStep: step,
6847
- stderr: result.stderr,
6848
- stdout: result.stdout,
6849
- exitCode: result.exitCode,
6850
- source: result.source
6851
- });
6852
- 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;
6853
7328
  }
6854
- executed += 1;
7329
+ sanitized[key] = sanitizeValue(entry, privateMetadata);
6855
7330
  }
6856
- const summary = {
6857
- machineId: plan.machineId,
6858
- mode: "apply",
6859
- steps: plan.steps,
6860
- executed
6861
- };
6862
- recordSetupRun(plan.machineId, "completed", summary);
6863
- 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));
6864
7338
  }
6865
7339
 
6866
- // src/commands/sync.ts
6867
- import { existsSync as existsSync7, lstatSync, readFileSync as readFileSync5, symlinkSync, copyFileSync } from "fs";
6868
- import { homedir as homedir5 } from "os";
6869
- function quote4(value) {
6870
- return `'${value.replace(/'/g, `'\\''`)}'`;
7340
+ // src/commands/manifest.ts
7341
+ function manifestList() {
7342
+ return readManifest();
6871
7343
  }
6872
- function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
6873
- const quotedPackageName = quote4(packageName);
6874
- if (manager === "bun") {
6875
- 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`;
6876
- }
6877
- if (manager === "brew") {
6878
- return `if brew list --versions ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
6879
- }
6880
- if (manager === "apt") {
6881
- return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
6882
- }
6883
- 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;
6884
7352
  }
6885
- function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
6886
- if (manager === "bun") {
6887
- return `bun install -g ${quote4(packageName)}`;
6888
- }
6889
- if (manager === "brew") {
6890
- return `brew install ${quote4(packageName)}`;
6891
- }
6892
- if (manager === "apt") {
6893
- return `sudo apt-get install -y ${quote4(packageName)}`;
6894
- }
6895
- return packageName;
7353
+ function manifestBootstrapCurrentMachine() {
7354
+ return manifestAdd(detectCurrentMachineManifest());
6896
7355
  }
6897
- function detectPackageActions(machine, runner) {
6898
- return (machine.packages || []).map((pkg, index) => {
6899
- const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
6900
- const check2 = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
6901
- if (check2.exitCode !== 0) {
6902
- throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check2));
6903
- }
6904
- const installed = check2.stdout.split(`
6905
- `).some((line) => line.trim() === "installed=1");
6906
- return {
6907
- id: `package-${index + 1}`,
6908
- title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
6909
- command: packageInstallCommand(machine, pkg.name, manager),
6910
- status: installed ? "ok" : "missing",
6911
- kind: "package"
6912
- };
6913
- });
7356
+ function manifestGet(machineId) {
7357
+ return getManifestMachine(machineId);
6914
7358
  }
6915
- function detectFileActions(machine) {
6916
- if ((machine.files || []).length > 0 && resolveMachineCommand(machine.id, "true").source !== "local") {
6917
- 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;
6918
7381
  }
6919
- return (machine.files || []).map((file, index) => {
6920
- const sourceExists = existsSync7(file.source);
6921
- const targetExists = existsSync7(file.target);
6922
- let status = "missing";
6923
- if (sourceExists && targetExists) {
6924
- if (file.mode === "symlink") {
6925
- status = lstatSync(file.target).isSymbolicLink() ? "ok" : "drifted";
6926
- } else {
6927
- const source = readFileSync5(file.source, "utf8");
6928
- const target = readFileSync5(file.target, "utf8");
6929
- status = source === target ? "ok" : "drifted";
6930
- }
6931
- }
6932
- const command = file.mode === "symlink" ? `ln -sfn ${quote4(file.source)} ${quote4(file.target)}` : `cp ${quote4(file.source)} ${quote4(file.target)}`;
6933
- return {
6934
- id: `file-${index + 1}`,
6935
- title: `${status === "ok" ? "File in sync" : "Reconcile file"} ${file.target}`,
6936
- command,
6937
- status,
6938
- kind: "file"
6939
- };
6940
- });
6941
7382
  }
6942
- function buildSyncPlan(machineId, runner = runMachineCommand) {
7383
+ function getStatus() {
6943
7384
  const manifest = readManifest();
6944
- const currentMachineId = getLocalMachineId();
6945
- const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
6946
- if (machineId && !selected) {
6947
- throw new Error(`Machine not found in manifest: ${machineId}`);
6948
- }
6949
- const target = selected || {
6950
- id: currentMachineId,
6951
- platform: "linux",
6952
- 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")
6953
7416
  };
6954
- const actions = [
6955
- ...detectPackageActions(target, runner),
6956
- ...detectFileActions(target)
6957
- ];
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);
6958
7433
  return {
6959
- machineId: target.id,
6960
- mode: "plan",
6961
- actions,
6962
- 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
+ ]
6963
7446
  };
6964
7447
  }
6965
- function applyFileAction(command) {
6966
- const [verb, source, target] = command.split(" ");
6967
- if (verb === "cp" && source && target) {
6968
- ensureParentDir(target);
6969
- copyFileSync(source.slice(1, -1), target.slice(1, -1));
6970
- return;
6971
- }
6972
- if (verb === "ln" && source && target) {
6973
- const sourcePath = command.match(/ln -sfn '(.+)' '(.+)'/)?.[1];
6974
- const targetPath = command.match(/ln -sfn '(.+)' '(.+)'/)?.[2];
6975
- if (!sourcePath || !targetPath) {
6976
- throw new Error(`Unable to parse symlink command: ${command}`);
6977
- }
6978
- ensureParentDir(targetPath);
6979
- try {
6980
- Bun.file(targetPath).delete();
6981
- } catch {}
6982
- symlinkSync(sourcePath, targetPath);
6983
- }
7448
+
7449
+ // src/commands/serve.ts
7450
+ function escapeHtml(value) {
7451
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
6984
7452
  }
6985
- function runSync(machineId, options = {}, runner = runMachineCommand) {
6986
- const plan = buildSyncPlan(machineId, runner);
6987
- if (!options.apply) {
6988
- return plan;
6989
- }
6990
- if (!options.yes) {
6991
- throw new Error("Sync execution requires --yes.");
6992
- }
6993
- let executed = 0;
6994
- for (const action of plan.actions) {
6995
- if (action.status === "ok")
6996
- continue;
6997
- if (action.kind === "file") {
6998
- applyFileAction(action.command);
6999
- } else {
7000
- const result = runner(plan.machineId, action.command);
7001
- if (result.exitCode !== 0) {
7002
- recordSyncRun(plan.machineId, "failed", {
7003
- executed,
7004
- failedAction: action,
7005
- stderr: result.stderr,
7006
- stdout: result.stdout,
7007
- exitCode: result.exitCode,
7008
- source: result.source
7009
- });
7010
- throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
7011
- }
7012
- }
7013
- executed += 1;
7014
- }
7015
- const summary = {
7016
- machineId: plan.machineId,
7017
- mode: "apply",
7018
- actions: plan.actions,
7019
- 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
+ ]
7020
7480
  };
7021
- recordSyncRun(plan.machineId, "completed", summary);
7022
- return summary;
7023
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
+ }
7024
7632
 
7025
- // src/agent/runtime.ts
7026
- function getAgentStatus(machineId = getLocalMachineId()) {
7027
- return listHeartbeats(machineId).map((heartbeat) => ({
7028
- machineId: heartbeat.machine_id,
7029
- pid: heartbeat.pid,
7030
- status: heartbeat.status,
7031
- updatedAt: heartbeat.updated_at
7032
- }));
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>`;
7033
7646
  }
7034
7647
 
7035
- // src/compatibility.ts
7036
- var DEFAULT_COMMANDS = [
7037
- { command: "bun", required: true },
7038
- { command: "machines", required: true }
7039
- ];
7040
- function defaultPackages() {
7041
- return [{ name: "@hasna/machines", command: "machines", expectedVersion: getPackageVersion(), required: true }];
7042
- }
7043
- function shellQuote6(value) {
7044
- return `'${value.replace(/'/g, "'\\''")}'`;
7045
- }
7046
- function commandId(value) {
7047
- return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
7048
- }
7049
- function packageCommand(name) {
7050
- if (name === "@hasna/knowledge")
7051
- return "knowledge";
7052
- if (name === "@hasna/machines")
7053
- return "machines";
7054
- return name.split("/").pop() ?? name;
7055
- }
7056
- function firstLine(value) {
7057
- return value.trim().split(/\r?\n/).find(Boolean) ?? "";
7058
- }
7059
- function extractVersion(value) {
7060
- const match = value.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/);
7061
- return match?.[0] ?? null;
7062
- }
7063
- function statusFor(required, ok) {
7064
- if (ok)
7065
- return "ok";
7066
- return required === false ? "warn" : "fail";
7067
- }
7068
- function makeCheck2(input) {
7069
- return {
7070
- id: input.id,
7071
- kind: input.kind,
7072
- status: input.status,
7073
- target: input.target,
7074
- expected: input.expected ?? null,
7075
- actual: input.actual ?? null,
7076
- detail: input.detail,
7077
- source: input.source
7078
- };
7079
- }
7080
- function parseKeyValue(stdout) {
7081
- const result = {};
7082
- for (const line of stdout.split(/\r?\n/)) {
7083
- const idx = line.indexOf("=");
7084
- if (idx <= 0)
7085
- continue;
7086
- result[line.slice(0, idx)] = line.slice(idx + 1);
7087
- }
7088
- return result;
7089
- }
7090
- function defaultRunner2(machineId, command) {
7091
- return runMachineCommand(machineId, command);
7092
- }
7093
- function inspectCommand(machineId, spec, runner) {
7094
- const command = shellQuote6(spec.command);
7095
- const versionArgs = spec.versionArgs ?? "--version";
7096
- const script = [
7097
- `cmd=${command}`,
7098
- 'path="$(command -v "$cmd" 2>/dev/null || true)"',
7099
- 'printf "path=%s\\n" "$path"',
7100
- 'if [ -n "$path" ]; then version="$("$cmd" ' + versionArgs + ' 2>/dev/null || true)"; printf "version=%s\\n" "$version"; fi'
7101
- ].join("; ");
7102
- const result = runner(machineId, script);
7103
- const parsed = parseKeyValue(result.stdout);
7104
- return {
7105
- path: parsed.path || null,
7106
- version: parsed.version ? firstLine(parsed.version) : null,
7107
- exitCode: result.exitCode,
7108
- source: result.source,
7109
- stderr: result.stderr
7110
- };
7111
- }
7112
- function fieldCommand(field) {
7113
- const regex = field === "name" ? String.raw`s/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p` : String.raw`s/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p`;
7114
- return [
7115
- `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`,
7116
- `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`,
7117
- `else sed -n '${regex}' "$pkg" | head -n 1`,
7118
- "fi"
7119
- ].join("; ");
7120
- }
7121
- function inspectWorkspace(machineId, spec, runner) {
7122
- const script = [
7123
- `path=${shellQuote6(spec.path)}`,
7124
- 'printf "exists=%s\\n" "$(test -d "$path" && printf yes || printf no)"',
7125
- 'pkg="$path/package.json"',
7126
- 'printf "package_json=%s\\n" "$(test -f "$pkg" && printf yes || printf no)"',
7127
- `if [ -f "$pkg" ]; then printf "package_name=%s\\n" "$(${fieldCommand("name")})"; printf "version=%s\\n" "$(${fieldCommand("version")})"; fi`
7128
- ].join("; ");
7129
- const result = runner(machineId, script);
7130
- const parsed = parseKeyValue(result.stdout);
7131
- return {
7132
- exists: parsed.exists === "yes",
7133
- packageJson: parsed.package_json === "yes",
7134
- packageName: parsed.package_name || null,
7135
- version: parsed.version || null,
7136
- source: result.source,
7137
- stderr: result.stderr
7138
- };
7648
+ // src/commands/setup.ts
7649
+ import { homedir as homedir4 } from "os";
7650
+ function quote3(value) {
7651
+ return `'${value.replace(/'/g, `'\\''`)}'`;
7139
7652
  }
7140
- function commandCheck(machineId, spec, runner) {
7141
- const inspection = inspectCommand(machineId, spec, runner);
7142
- const found = Boolean(inspection.path);
7143
- const checks = [
7144
- makeCheck2({
7145
- id: `command:${commandId(spec.command)}:path`,
7146
- kind: "command",
7147
- status: statusFor(spec.required, found),
7148
- target: spec.command,
7149
- expected: "available",
7150
- actual: inspection.path ?? "missing",
7151
- detail: found ? `found at ${inspection.path}` : inspection.stderr || "command missing",
7152
- source: inspection.source
7153
- })
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
+ }
7154
7667
  ];
7155
- if (spec.expectedVersion) {
7156
- const actualVersion = extractVersion(inspection.version ?? "");
7157
- checks.push(makeCheck2({
7158
- id: `command:${commandId(spec.command)}:version`,
7159
- kind: "command",
7160
- status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
7161
- target: spec.command,
7162
- expected: spec.expectedVersion,
7163
- actual: actualVersion ?? inspection.version ?? "missing",
7164
- detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
7165
- source: inspection.source
7166
- }));
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
+ });
7167
7709
  }
7168
- 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;
7169
7717
  }
7170
- function packageCheck(machineId, spec, runner) {
7171
- const command = spec.command ?? packageCommand(spec.name);
7172
- const inspection = inspectCommand(machineId, { command, expectedVersion: spec.expectedVersion, required: spec.required }, runner);
7173
- const found = Boolean(inspection.path);
7174
- const checks = [
7175
- makeCheck2({
7176
- id: `package:${commandId(spec.name)}:command`,
7177
- kind: "package",
7178
- status: statusFor(spec.required, found),
7179
- target: spec.name,
7180
- expected: command,
7181
- actual: inspection.path ?? "missing",
7182
- detail: found ? `${command} found at ${inspection.path}` : `${command} command missing`,
7183
- source: inspection.source
7184
- })
7185
- ];
7186
- if (spec.expectedVersion) {
7187
- const actualVersion = extractVersion(inspection.version ?? "");
7188
- checks.push(makeCheck2({
7189
- id: `package:${commandId(spec.name)}:version`,
7190
- kind: "package",
7191
- status: actualVersion === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
7192
- target: spec.name,
7193
- expected: spec.expectedVersion,
7194
- actual: actualVersion ?? inspection.version ?? "missing",
7195
- detail: actualVersion ? `version output: ${inspection.version}` : "version unavailable",
7196
- source: inspection.source
7197
- }));
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}`);
7198
7744
  }
7199
- 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
+ };
7200
7757
  }
7201
- function workspaceCheck(machineId, spec, runner) {
7202
- const inspection = inspectWorkspace(machineId, spec, runner);
7203
- const target = spec.label ?? spec.path;
7204
- const checks = [
7205
- makeCheck2({
7206
- id: `workspace:${commandId(target)}:path`,
7207
- kind: "workspace",
7208
- status: statusFor(spec.required, inspection.exists),
7209
- target,
7210
- expected: spec.path,
7211
- actual: inspection.exists ? "exists" : "missing",
7212
- detail: inspection.exists ? `workspace exists at ${spec.path}` : inspection.stderr || `workspace missing at ${spec.path}`,
7213
- source: inspection.source
7214
- })
7215
- ];
7216
- if (spec.expectedPackageName) {
7217
- checks.push(makeCheck2({
7218
- id: `workspace:${commandId(target)}:package-name`,
7219
- kind: "workspace",
7220
- status: inspection.packageName === spec.expectedPackageName ? "ok" : statusFor(spec.required, false),
7221
- target,
7222
- expected: spec.expectedPackageName,
7223
- actual: inspection.packageName ?? (inspection.packageJson ? "missing-name" : "missing-package-json"),
7224
- detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
7225
- source: inspection.source
7226
- }));
7758
+ function runSetup(machineId, options = {}, runner = runMachineCommand) {
7759
+ const plan = buildSetupPlan(machineId);
7760
+ if (!options.apply) {
7761
+ return plan;
7227
7762
  }
7228
- if (spec.expectedVersion) {
7229
- checks.push(makeCheck2({
7230
- id: `workspace:${commandId(target)}:version`,
7231
- kind: "workspace",
7232
- status: inspection.version === spec.expectedVersion ? "ok" : statusFor(spec.required, false),
7233
- target,
7234
- expected: spec.expectedVersion,
7235
- actual: inspection.version ?? (inspection.packageJson ? "missing-version" : "missing-package-json"),
7236
- detail: inspection.packageJson ? "package.json inspected" : "package.json missing",
7237
- source: inspection.source
7238
- }));
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;
7239
7781
  }
7240
- return checks;
7241
- }
7242
- function checkMachineCompatibility(options = {}) {
7243
- const machineId = options.machineId ?? getLocalMachineId();
7244
- const runner = options.runner ?? defaultRunner2;
7245
- const commands = options.commands ?? DEFAULT_COMMANDS;
7246
- const packages = options.packages ?? defaultPackages();
7247
- const workspaces = options.workspaces ?? [];
7248
- const checks = [];
7249
- for (const spec of commands)
7250
- checks.push(...commandCheck(machineId, spec, runner));
7251
- for (const spec of packages)
7252
- checks.push(...packageCheck(machineId, spec, runner));
7253
- for (const spec of workspaces)
7254
- checks.push(...workspaceCheck(machineId, spec, runner));
7255
7782
  const summary = {
7256
- ok: checks.filter((check2) => check2.status === "ok").length,
7257
- warn: checks.filter((check2) => check2.status === "warn").length,
7258
- fail: checks.filter((check2) => check2.status === "fail").length
7259
- };
7260
- return {
7261
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
7262
- package: {
7263
- name: MACHINES_PACKAGE_NAME,
7264
- version: getPackageVersion()
7265
- },
7266
- capabilities: getMachinesConsumerCapabilities(),
7267
- ok: summary.fail === 0,
7268
- machine_id: machineId,
7269
- source: checks[0]?.source ?? "local",
7270
- generated_at: (options.now ?? new Date).toISOString(),
7271
- checks,
7272
- summary
7783
+ machineId: plan.machineId,
7784
+ mode: "apply",
7785
+ steps: plan.steps,
7786
+ executed
7273
7787
  };
7788
+ recordSetupRun(plan.machineId, "completed", summary);
7789
+ return summary;
7274
7790
  }
7275
7791
 
7276
- // src/pg-migrations.ts
7277
- var PG_MIGRATIONS = [
7278
- `
7279
- CREATE TABLE IF NOT EXISTS agent_heartbeats (
7280
- machine_id TEXT NOT NULL,
7281
- pid INTEGER NOT NULL,
7282
- status TEXT NOT NULL,
7283
- updated_at TIMESTAMPTZ NOT NULL,
7284
- PRIMARY KEY (machine_id, pid)
7285
- );
7286
-
7287
- CREATE TABLE IF NOT EXISTS setup_runs (
7288
- id TEXT PRIMARY KEY,
7289
- machine_id TEXT NOT NULL,
7290
- status TEXT NOT NULL,
7291
- details_json TEXT NOT NULL DEFAULT '[]',
7292
- created_at TIMESTAMPTZ NOT NULL,
7293
- updated_at TIMESTAMPTZ NOT NULL
7294
- );
7295
-
7296
- CREATE TABLE IF NOT EXISTS sync_runs (
7297
- id TEXT PRIMARY KEY,
7298
- machine_id TEXT NOT NULL,
7299
- status TEXT NOT NULL,
7300
- actions_json TEXT NOT NULL DEFAULT '[]',
7301
- created_at TIMESTAMPTZ NOT NULL,
7302
- updated_at TIMESTAMPTZ NOT NULL
7303
- );
7304
- `
7305
- ];
7306
-
7307
- // src/remote-storage.ts
7308
- import pg from "pg";
7309
- function translatePlaceholders(sql) {
7310
- let index = 0;
7311
- return sql.replace(/\?/g, () => `$${++index}`);
7312
- }
7313
- function normalizeParams(params) {
7314
- const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
7315
- return flat.map((value) => value === undefined ? null : value);
7316
- }
7317
- function sslConfigFor(connectionString) {
7318
- 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, `'\\''`)}'`;
7319
7797
  }
7320
-
7321
- class PgAdapterAsync {
7322
- pool;
7323
- constructor(connectionString) {
7324
- 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`;
7325
7802
  }
7326
- async run(sql, ...params) {
7327
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
7328
- 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`;
7329
7805
  }
7330
- async all(sql, ...params) {
7331
- const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
7332
- 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`;
7333
7808
  }
7334
- async close() {
7335
- 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)}`;
7336
7820
  }
7821
+ return packageName;
7337
7822
  }
7338
-
7339
- // src/storage-sync.ts
7340
- var STORAGE_TABLES = [
7341
- "agent_heartbeats",
7342
- "setup_runs",
7343
- "sync_runs"
7344
- ];
7345
- var MACHINES_STORAGE_ENV = "HASNA_MACHINES_DATABASE_URL";
7346
- var MACHINES_STORAGE_FALLBACK_ENV = "MACHINES_DATABASE_URL";
7347
- var MACHINES_STORAGE_MODE_ENV = "HASNA_MACHINES_STORAGE_MODE";
7348
- var MACHINES_STORAGE_MODE_FALLBACK_ENV = "MACHINES_STORAGE_MODE";
7349
- var STORAGE_DATABASE_ENV = [MACHINES_STORAGE_ENV, MACHINES_STORAGE_FALLBACK_ENV];
7350
- var PRIMARY_KEYS = {
7351
- agent_heartbeats: ["machine_id", "pid"],
7352
- setup_runs: ["id"],
7353
- sync_runs: ["id"]
7354
- };
7355
- function readEnv2(name) {
7356
- const value = process.env[name]?.trim();
7357
- 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
+ });
7358
7840
  }
7359
- function normalizeStorageMode(value) {
7360
- const normalized = value?.trim().toLowerCase();
7361
- if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
7362
- return normalized;
7363
- 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
+ });
7364
7867
  }
7365
- function getStorageDatabaseEnvName() {
7366
- for (const name of STORAGE_DATABASE_ENV) {
7367
- if (readEnv2(name))
7368
- 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}`);
7369
7874
  }
7370
- return null;
7371
- }
7372
- function getStorageDatabaseEnv() {
7373
- const name = getStorageDatabaseEnvName();
7374
- return name ? { name } : null;
7375
- }
7376
- function getStorageDatabaseUrl() {
7377
- const env = getStorageDatabaseEnv();
7378
- return env ? readEnv2(env.name) ?? null : null;
7379
- }
7380
- function getStorageMode() {
7381
- const mode = normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv2(MACHINES_STORAGE_MODE_FALLBACK_ENV));
7382
- if (mode)
7383
- return mode;
7384
- 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
+ };
7385
7890
  }
7386
- async function getStoragePg() {
7387
- const url = getStorageDatabaseUrl();
7388
- if (!url) {
7389
- 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;
7390
7897
  }
7391
- return new PgAdapterAsync(url);
7392
- }
7393
- async function runStorageMigrations(remote) {
7394
- for (const sql of PG_MIGRATIONS)
7395
- await remote.run(sql);
7396
- }
7397
- async function storagePush(options) {
7398
- const remote = await getStoragePg();
7399
- const db = getDb();
7400
- try {
7401
- await runStorageMigrations(remote);
7402
- const results = [];
7403
- for (const table of resolveTables(options?.tables)) {
7404
- 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}`);
7405
7903
  }
7406
- recordSyncMeta(db, "push", results);
7407
- return results;
7408
- } finally {
7409
- await remote.close();
7904
+ ensureParentDir(targetPath);
7905
+ try {
7906
+ Bun.file(targetPath).delete();
7907
+ } catch {}
7908
+ symlinkSync(sourcePath, targetPath);
7410
7909
  }
7411
7910
  }
7412
- async function storagePull(options) {
7413
- const remote = await getStoragePg();
7414
- const db = getDb();
7415
- try {
7416
- await runStorageMigrations(remote);
7417
- const results = [];
7418
- for (const table of resolveTables(options?.tables)) {
7419
- 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
+ }
7420
7938
  }
7421
- recordSyncMeta(db, "pull", results);
7422
- return results;
7423
- } finally {
7424
- await remote.close();
7939
+ executed += 1;
7425
7940
  }
7426
- }
7427
- async function storageSync(options) {
7428
- const pull = await storagePull(options);
7429
- const push = await storagePush(options);
7430
- return { pull, push };
7431
- }
7432
- function getSyncMetaAll() {
7433
- const db = getDb();
7434
- ensureSyncMetaTable(db);
7435
- return db.query("SELECT table_name, last_synced_at, direction FROM _machines_sync_meta ORDER BY table_name, direction").all();
7436
- }
7437
- function getStorageStatus() {
7438
- const activeEnv = getStorageDatabaseEnv();
7439
- return {
7440
- configured: Boolean(activeEnv),
7441
- mode: getStorageMode(),
7442
- env: STORAGE_DATABASE_ENV,
7443
- activeEnv: activeEnv?.name ?? null,
7444
- service: "machines",
7445
- tables: STORAGE_TABLES,
7446
- sync: getSyncMetaAll()
7941
+ const summary = {
7942
+ machineId: plan.machineId,
7943
+ mode: "apply",
7944
+ actions: plan.actions,
7945
+ executed
7447
7946
  };
7947
+ recordSyncRun(plan.machineId, "completed", summary);
7948
+ return summary;
7448
7949
  }
7449
- function resolveTables(tables) {
7450
- if (!tables || tables.length === 0)
7451
- return [...STORAGE_TABLES];
7452
- const allowed = new Set(STORAGE_TABLES);
7453
- const requested = tables.map((table) => table.trim()).filter(Boolean);
7454
- const invalid = requested.filter((table) => !allowed.has(table));
7455
- if (invalid.length > 0)
7456
- throw new Error(`Unknown machines storage table(s): ${invalid.join(", ")}`);
7457
- return requested;
7458
- }
7459
- async function pushTable(db, remote, table) {
7460
- const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
7461
- try {
7462
- if (!tableExists(db, table))
7463
- return result;
7464
- const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
7465
- result.rowsRead = rows.length;
7466
- if (rows.length === 0)
7467
- return result;
7468
- const remoteColumns = await getRemoteColumns(remote, table);
7469
- const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
7470
- result.rowsWritten = await upsertPg(remote, table, columns, rows);
7471
- } catch (error) {
7472
- result.errors.push(error instanceof Error ? error.message : String(error));
7473
- }
7474
- 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 }];
7475
7958
  }
7476
- async function pullTable(remote, db, table) {
7477
- const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
7478
- try {
7479
- if (!tableExists(db, table))
7480
- return result;
7481
- const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
7482
- result.rowsRead = rows.length;
7483
- if (rows.length === 0)
7484
- return result;
7485
- const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
7486
- result.rowsWritten = upsertSqlite(db, table, columns, rows);
7487
- } catch (error) {
7488
- result.errors.push(error instanceof Error ? error.message : String(error));
7489
- }
7490
- return result;
7959
+ function shellQuote6(value) {
7960
+ return `'${value.replace(/'/g, "'\\''")}'`;
7491
7961
  }
7492
- async function getRemoteColumns(remote, table) {
7493
- const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
7494
- return new Set(rows.map((row) => row.column_name));
7962
+ function commandId(value) {
7963
+ return value.replace(/[^a-zA-Z0-9_.@/-]+/g, "-").replace(/^-+|-+$/g, "");
7495
7964
  }
7496
- function filterRemoteColumns(remoteColumns, columns) {
7497
- if (remoteColumns.size === 0)
7498
- return columns;
7499
- 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;
7500
7971
  }
7501
- function filterLocalColumns(db, table, columns) {
7502
- const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
7503
- const allowed = new Set(rows.map((row) => row.name));
7504
- return columns.filter((column) => allowed.has(column));
7972
+ function firstLine(value) {
7973
+ return value.trim().split(/\r?\n/).find(Boolean) ?? "";
7505
7974
  }
7506
- async function upsertPg(remote, table, columns, rows) {
7507
- if (columns.length === 0)
7508
- return 0;
7509
- const primaryKeys = PRIMARY_KEYS[table];
7510
- const columnList = columns.map(quoteIdent).join(", ");
7511
- const placeholders = columns.map(() => "?").join(", ");
7512
- const keyList = primaryKeys.map(quoteIdent).join(", ");
7513
- const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
7514
- const fallbackKey = primaryKeys[0];
7515
- const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
7516
- for (const row of rows) {
7517
- await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
7518
- ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column])));
7519
- }
7520
- 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;
7521
7978
  }
7522
- function upsertSqlite(db, table, columns, rows) {
7523
- if (columns.length === 0)
7524
- return 0;
7525
- const primaryKeys = PRIMARY_KEYS[table];
7526
- const columnList = columns.map(quoteIdent).join(", ");
7527
- const placeholders = columns.map(() => "?").join(", ");
7528
- const keyList = primaryKeys.map(quoteIdent).join(", ");
7529
- const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
7530
- const fallbackKey = primaryKeys[0];
7531
- const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
7532
- const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
7533
- ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
7534
- const insert = db.transaction((batch) => {
7535
- for (const row of batch)
7536
- statement.run(...columns.map((column) => coerceForSqlite(row[column])));
7537
- });
7538
- insert(rows);
7539
- return rows.length;
7979
+ function statusFor(required, ok) {
7980
+ if (ok)
7981
+ return "ok";
7982
+ return required === false ? "warn" : "fail";
7540
7983
  }
7541
- function recordSyncMeta(db, direction, results) {
7542
- ensureSyncMetaTable(db);
7543
- const now = new Date().toISOString();
7544
- const statement = db.query(`
7545
- INSERT INTO _machines_sync_meta (table_name, last_synced_at, direction)
7546
- VALUES (?, ?, ?)
7547
- ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
7548
- `);
7549
- for (const result of results) {
7550
- 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)
7551
8001
  continue;
7552
- statement.run(result.table, now, direction);
8002
+ result[line.slice(0, idx)] = line.slice(idx + 1);
7553
8003
  }
8004
+ return result;
7554
8005
  }
7555
- function ensureSyncMetaTable(db) {
7556
- db.exec(`
7557
- CREATE TABLE IF NOT EXISTS _machines_sync_meta (
7558
- table_name TEXT NOT NULL,
7559
- last_synced_at TEXT,
7560
- direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
7561
- PRIMARY KEY (table_name, direction)
7562
- )
7563
- `);
8006
+ function defaultRunner2(machineId, command2) {
8007
+ return runMachineCommand(machineId, command2);
7564
8008
  }
7565
- function tableExists(db, table) {
7566
- const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
7567
- 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
+ };
7568
8027
  }
7569
- function quoteIdent(identifier) {
7570
- 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("; ");
7571
8036
  }
7572
- function coerceForPg(value) {
7573
- if (value === undefined || value === null)
7574
- return null;
7575
- if (value instanceof Date)
7576
- return value.toISOString();
7577
- if (Buffer.isBuffer(value) || value instanceof Uint8Array)
7578
- return value;
7579
- if (typeof value === "object")
7580
- return JSON.stringify(value);
7581
- 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
+ };
7582
8055
  }
7583
- function coerceForSqlite(value) {
7584
- if (value === undefined || value === null)
7585
- return null;
7586
- if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
7587
- return value;
7588
- if (value instanceof Date)
7589
- return value.toISOString();
7590
- if (Buffer.isBuffer(value) || value instanceof Uint8Array)
7591
- return value;
7592
- if (typeof value === "object")
7593
- return JSON.stringify(value);
7594
- 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
+ };
7595
8190
  }
7596
8191
  // src/mcp/server.ts
7597
8192
  function buildServer(version = getPackageVersion()) {
7598
8193
  return createMcpServer(version);
7599
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
+ }
7600
8206
  function createMcpServer(version) {
7601
8207
  const server = new McpServer({ name: "machines", version });
7602
8208
  const events = new EventsClient2;
@@ -7623,16 +8229,69 @@ function createMcpServer(version) {
7623
8229
  }));
7624
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) }] }));
7625
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) }] }));
7626
- server.tool("machines_agent_status", "List current machine agent heartbeats.", {}, async () => ({
7627
- 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
+ }]
7628
8281
  }));
7629
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) }] }));
7630
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) }] }));
7631
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) }] }));
7632
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) }] }));
7633
- 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 }) => ({
7634
- content: [{ type: "text", text: JSON.stringify(discoverMachineTopology({ includeTailscale: include_tailscale !== false }), null, 2) }]
7635
- }));
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
+ });
7636
8295
  server.tool("machines_compatibility", "Check remote package, command, and workspace compatibility for open-* consumers.", {
7637
8296
  machine_id: exports_external.string().optional().describe("Machine identifier"),
7638
8297
  commands: exports_external.array(exports_external.object({
@@ -7684,9 +8343,16 @@ function createMcpServer(version) {
7684
8343
  }));
7685
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) }] }));
7686
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) }] }));
7687
- 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 }) => ({
7688
- content: [{ type: "text", text: JSON.stringify(resolveMachineRoute(machine_id, { includeTailscale: include_tailscale !== false }), null, 2) }]
7689
- }));
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
+ });
7690
8356
  server.tool("machines_workspace_resolve", "Resolve sync-safe repo and open-files roots for an open-* consumer.", {
7691
8357
  machine_id: exports_external.string().describe("Machine identifier"),
7692
8358
  project_id: exports_external.string().describe("Canonical project id"),