@hasna/machines 0.0.29 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -7912,996 +7912,610 @@ function manifestValidate() {
7912
7912
  // src/commands/setup.ts
7913
7913
  import { homedir as homedir3 } from "os";
7914
7914
  init_db();
7915
- function quote(value) {
7916
- return `'${value.replace(/'/g, `'\\''`)}'`;
7915
+
7916
+ // src/remote.ts
7917
+ init_db();
7918
+ import { spawnSync as spawnSync2 } from "child_process";
7919
+ import { hostname as hostname4 } from "os";
7920
+
7921
+ // src/topology.ts
7922
+ init_db();
7923
+ import { existsSync as existsSync5 } from "fs";
7924
+ import { arch as arch2, hostname as hostname3, platform as platform2, userInfo as userInfo2 } from "os";
7925
+ import { spawnSync } from "child_process";
7926
+ init_paths();
7927
+ var MACHINES_CONSUMER_CONTRACT_VERSION = 1;
7928
+ var MACHINES_PACKAGE_NAME = "@hasna/machines";
7929
+ var DEFAULT_MACHINE_RESOLVER_TTL_MS = 24 * 60 * 60 * 1000;
7930
+ var MACHINES_CONSUMER_CAPABILITIES = {
7931
+ topology: true,
7932
+ compatibility: true,
7933
+ route_resolution: true,
7934
+ cli_json_fallback: true,
7935
+ workspace_path_mapping: true,
7936
+ workspace_diagnostics: true,
7937
+ schema_artifacts: true,
7938
+ cacheability_metadata: true,
7939
+ resolver_snapshots: true,
7940
+ field_capability_descriptors: true
7941
+ };
7942
+ function getMachinesConsumerCapabilities() {
7943
+ return { ...MACHINES_CONSUMER_CAPABILITIES };
7917
7944
  }
7918
- function buildBaseSteps(machine) {
7919
- const steps = [
7920
- {
7921
- id: "workspace",
7922
- title: "Ensure workspace directory exists",
7923
- command: `mkdir -p ${quote(machine.workspacePath)}`,
7924
- manager: "shell"
7925
- },
7926
- {
7927
- id: "bun",
7928
- title: "Install Bun if missing",
7929
- command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
7930
- manager: "shell"
7931
- }
7932
- ];
7933
- if (machine.platform === "linux") {
7934
- steps.push({
7935
- id: "apt-base",
7936
- title: "Install core Linux tooling",
7937
- command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
7938
- manager: "apt",
7939
- privileged: true
7940
- });
7941
- } else if (machine.platform === "macos") {
7942
- steps.push({
7943
- id: "brew-base",
7944
- title: "Install Homebrew if missing",
7945
- command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
7946
- manager: "brew"
7947
- });
7948
- steps.push({
7949
- id: "brew-core",
7950
- title: "Install core macOS tooling",
7951
- command: "brew install git coreutils",
7952
- manager: "brew"
7953
- });
7954
- }
7955
- return steps;
7945
+ function normalizePlatform2(value = platform2()) {
7946
+ const normalized = value.toLowerCase();
7947
+ if (normalized === "darwin" || normalized === "macos")
7948
+ return "macos";
7949
+ if (normalized === "win32" || normalized === "windows")
7950
+ return "windows";
7951
+ if (normalized === "linux")
7952
+ return "linux";
7953
+ return value;
7956
7954
  }
7957
- function buildPackageSteps(machine) {
7958
- return (machine.packages || []).map((pkg, index) => {
7959
- const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
7960
- let command = pkg.name;
7961
- if (manager === "bun") {
7962
- command = `bun install -g ${quote(pkg.name)}`;
7963
- } else if (manager === "brew") {
7964
- command = `brew install ${quote(pkg.name)}`;
7965
- } else if (manager === "apt") {
7966
- command = `sudo apt-get install -y ${quote(pkg.name)}`;
7967
- }
7968
- return {
7969
- id: `package-${index + 1}`,
7970
- title: `Install package ${pkg.name}`,
7971
- command,
7972
- manager,
7973
- privileged: manager === "apt"
7974
- };
7955
+ function defaultRunner(command) {
7956
+ const result = spawnSync("bash", ["-c", command], {
7957
+ encoding: "utf8",
7958
+ env: process.env
7975
7959
  });
7976
- }
7977
- function buildSetupPlan(machineId) {
7978
- const manifest = readManifest();
7979
- const currentMachineId = getLocalMachineId();
7980
- const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
7981
- const target = selected || {
7982
- id: currentMachineId,
7983
- platform: "linux",
7984
- workspacePath: `${homedir3()}/workspace`
7985
- };
7986
- const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
7987
7960
  return {
7988
- machineId: target.id,
7989
- mode: "plan",
7990
- steps,
7991
- executed: 0
7961
+ stdout: result.stdout || "",
7962
+ stderr: result.stderr || "",
7963
+ exitCode: result.status ?? 1
7992
7964
  };
7993
7965
  }
7994
- function runSetup(machineId, options = {}) {
7995
- const plan = buildSetupPlan(machineId);
7996
- if (!options.apply) {
7997
- return plan;
7966
+ function hasCommand(command, runner) {
7967
+ return runner(`command -v ${command} >/dev/null 2>&1`).exitCode === 0;
7968
+ }
7969
+ function parseTailscaleStatus(raw) {
7970
+ try {
7971
+ const parsed = JSON.parse(raw);
7972
+ if (!parsed || typeof parsed !== "object")
7973
+ return null;
7974
+ return parsed;
7975
+ } catch {
7976
+ return null;
7998
7977
  }
7999
- if (!options.yes) {
8000
- throw new Error("Setup execution requires --yes.");
7978
+ }
7979
+ function loadTailscalePeers(runner, warnings) {
7980
+ const peers = new Map;
7981
+ if (!hasCommand("tailscale", runner)) {
7982
+ warnings.push("tailscale_not_available");
7983
+ return peers;
8001
7984
  }
8002
- let executed = 0;
8003
- for (const step of plan.steps) {
8004
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
8005
- stdout: "pipe",
8006
- stderr: "pipe",
8007
- env: process.env
8008
- });
8009
- if (result.exitCode !== 0) {
8010
- recordSetupRun(plan.machineId, "failed", {
8011
- executed,
8012
- failedStep: step,
8013
- stderr: result.stderr.toString()
8014
- });
8015
- throw new Error(`Setup step failed (${step.id}): ${result.stderr.toString().trim()}`);
8016
- }
8017
- executed += 1;
7985
+ const result = runner("tailscale status --json");
7986
+ if (result.exitCode !== 0) {
7987
+ warnings.push(`tailscale_status_failed:${result.stderr.trim() || result.exitCode}`);
7988
+ return peers;
8018
7989
  }
8019
- const summary = {
8020
- machineId: plan.machineId,
8021
- mode: "apply",
8022
- steps: plan.steps,
8023
- executed
7990
+ const status = parseTailscaleStatus(result.stdout);
7991
+ if (!status) {
7992
+ warnings.push("tailscale_status_invalid_json");
7993
+ return peers;
7994
+ }
7995
+ const addPeer = (peer) => {
7996
+ if (!peer)
7997
+ return;
7998
+ const id = peer.HostName || peer.DNSName?.split(".")[0];
7999
+ if (id)
8000
+ peers.set(id, peer);
8024
8001
  };
8025
- recordSetupRun(plan.machineId, "completed", summary);
8026
- return summary;
8002
+ addPeer(status.Self);
8003
+ for (const peer of Object.values(status.Peer ?? {}))
8004
+ addPeer(peer);
8005
+ return peers;
8027
8006
  }
8028
-
8029
- // src/commands/backup.ts
8030
- import { homedir as homedir4, hostname as hostname3 } from "os";
8031
- import { join as join4 } from "path";
8032
- var MACHINES_BACKUP_BUCKET_ENV = "HASNA_MACHINES_S3_BUCKET";
8033
- var MACHINES_BACKUP_BUCKET_FALLBACK_ENV = "MACHINES_S3_BUCKET";
8034
- var MACHINES_BACKUP_PREFIX_ENV = "HASNA_MACHINES_S3_PREFIX";
8035
- var MACHINES_BACKUP_PREFIX_FALLBACK_ENV = "MACHINES_S3_PREFIX";
8036
- var DEFAULT_BACKUP_PREFIX = "machines";
8037
- function quote2(value) {
8038
- return `'${value.replace(/'/g, `'\\''`)}'`;
8007
+ function machineKeys(machine) {
8008
+ return [
8009
+ machine.id,
8010
+ machine.hostname,
8011
+ machine.tailscaleName?.split(".")[0],
8012
+ machine.tailscaleName,
8013
+ machine.sshAddress?.split("@").pop()
8014
+ ].filter((value) => Boolean(value));
8039
8015
  }
8040
- function readEnv(name) {
8041
- const value = process.env[name]?.trim();
8042
- return value || undefined;
8016
+ function findTailscalePeer(machine, machineId, peers) {
8017
+ if (machine) {
8018
+ for (const key of machineKeys(machine)) {
8019
+ const peer = peers.get(key) ?? peers.get(key.replace(/\.$/, ""));
8020
+ if (peer)
8021
+ return peer;
8022
+ }
8023
+ }
8024
+ return peers.get(machineId) ?? null;
8043
8025
  }
8044
- function readBackupBucketEnv() {
8045
- const primary = readEnv(MACHINES_BACKUP_BUCKET_ENV);
8046
- if (primary)
8047
- return { bucket: primary, bucketSource: MACHINES_BACKUP_BUCKET_ENV };
8048
- const fallback = readEnv(MACHINES_BACKUP_BUCKET_FALLBACK_ENV);
8049
- if (fallback)
8050
- return { bucket: fallback, bucketSource: MACHINES_BACKUP_BUCKET_FALLBACK_ENV };
8051
- return null;
8026
+ function envReachableHosts() {
8027
+ const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
8028
+ return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
8052
8029
  }
8053
- function readBackupPrefixEnv() {
8054
- const primary = readEnv(MACHINES_BACKUP_PREFIX_ENV);
8055
- if (primary)
8056
- return { prefix: primary, prefixSource: MACHINES_BACKUP_PREFIX_ENV };
8057
- const fallback = readEnv(MACHINES_BACKUP_PREFIX_FALLBACK_ENV);
8058
- if (fallback)
8059
- return { prefix: fallback, prefixSource: MACHINES_BACKUP_PREFIX_FALLBACK_ENV };
8060
- return null;
8030
+ function manifestHostReachable(target) {
8031
+ const overrides = envReachableHosts();
8032
+ if (overrides.size === 0)
8033
+ return null;
8034
+ return overrides.has(target);
8061
8035
  }
8062
- function resolveBackupTarget(options = {}) {
8063
- const explicitBucket = options.bucket?.trim();
8064
- const envBucket = explicitBucket ? null : readBackupBucketEnv();
8065
- const bucket = explicitBucket || envBucket?.bucket;
8066
- if (!bucket) {
8067
- throw new Error(`Missing S3 backup bucket. Pass --bucket or set ${MACHINES_BACKUP_BUCKET_ENV} or ${MACHINES_BACKUP_BUCKET_FALLBACK_ENV}.`);
8036
+ function routeHints(input) {
8037
+ const hints = [];
8038
+ if (input.machineId === input.localMachineId) {
8039
+ hints.push({ kind: "local", target: "localhost", reachable: true });
8068
8040
  }
8069
- const explicitPrefix = options.prefix?.trim();
8070
- const envPrefix = explicitPrefix ? null : readBackupPrefixEnv();
8071
- return {
8072
- bucket,
8073
- prefix: explicitPrefix || envPrefix?.prefix || DEFAULT_BACKUP_PREFIX,
8074
- bucketSource: explicitBucket ? "argument" : envBucket.bucketSource,
8075
- prefixSource: explicitPrefix ? "argument" : envPrefix?.prefixSource || "default"
8076
- };
8077
- }
8078
- function defaultBackupSources() {
8079
- const home = homedir4();
8080
- return [
8081
- join4(home, ".hasna"),
8082
- join4(home, ".ssh"),
8083
- join4(home, ".secrets")
8084
- ];
8085
- }
8086
- function buildBackupPlan(bucket, prefix) {
8087
- const target = resolveBackupTarget({ bucket, prefix });
8088
- const archivePath = join4(homedir4(), ".hasna", "machines", "backup.tgz");
8089
- const sources = defaultBackupSources();
8090
- const steps = [
8091
- {
8092
- id: "backup-archive",
8093
- title: "Create compressed machine backup archive",
8094
- command: `tar -czf ${quote2(archivePath)} ${sources.map((source) => quote2(source)).join(" ")}`,
8095
- manager: "shell"
8096
- },
8097
- {
8098
- id: "backup-upload",
8099
- title: "Upload archive to S3",
8100
- command: `aws s3 cp ${quote2(archivePath)} ${quote2(`s3://${target.bucket}/${target.prefix}/${hostname3()}-backup.tgz`)}`,
8101
- manager: "custom"
8102
- }
8103
- ];
8104
- return {
8105
- machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
8106
- mode: "plan",
8107
- steps,
8108
- executed: 0
8109
- };
8110
- }
8111
- function runBackup(bucket, prefix, options = {}) {
8112
- const plan = buildBackupPlan(bucket, prefix);
8113
- if (!options.apply)
8114
- return plan;
8115
- if (!options.yes) {
8116
- throw new Error("Backup execution requires --yes.");
8041
+ if (input.manifest?.sshAddress) {
8042
+ hints.push({ kind: "ssh", target: input.manifest.sshAddress, reachable: manifestHostReachable(input.manifest.sshAddress) });
8117
8043
  }
8118
- let executed = 0;
8119
- for (const step of plan.steps) {
8120
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
8121
- stdout: "pipe",
8122
- stderr: "pipe",
8123
- env: process.env
8124
- });
8125
- if (result.exitCode !== 0) {
8126
- throw new Error(`Backup step failed (${step.id}): ${result.stderr.toString().trim()}`);
8127
- }
8128
- executed += 1;
8044
+ if (input.manifest?.hostname) {
8045
+ hints.push({ kind: "lan", target: input.manifest.hostname, reachable: manifestHostReachable(input.manifest.hostname) });
8129
8046
  }
8130
- return {
8131
- machineId: plan.machineId,
8132
- mode: "apply",
8133
- steps: plan.steps,
8134
- executed
8135
- };
8047
+ const tailscaleTarget = input.manifest?.tailscaleName ?? input.peer?.DNSName ?? input.peer?.TailscaleIPs?.[0];
8048
+ if (tailscaleTarget) {
8049
+ hints.push({ kind: "tailscale", target: tailscaleTarget.replace(/\.$/, ""), reachable: input.peer?.Online ?? null });
8050
+ }
8051
+ return hints;
8136
8052
  }
8137
-
8138
- // src/commands/cert.ts
8139
- import { homedir as homedir5, platform as platform2 } from "os";
8140
- import { join as join5 } from "path";
8141
- function quote3(value) {
8142
- return `'${value.replace(/'/g, `'\\''`)}'`;
8053
+ function routeRank(hint) {
8054
+ if (hint.kind === "local")
8055
+ return 0;
8056
+ if (hint.reachable === true && hint.kind === "ssh")
8057
+ return 1;
8058
+ if (hint.reachable === true && hint.kind === "lan")
8059
+ return 2;
8060
+ if (hint.reachable === true && hint.kind === "tailscale")
8061
+ return 3;
8062
+ if (hint.reachable === false)
8063
+ return 8;
8064
+ if (hint.kind === "ssh")
8065
+ return 4;
8066
+ if (hint.kind === "lan")
8067
+ return 5;
8068
+ if (hint.kind === "tailscale")
8069
+ return 6;
8070
+ return 9;
8143
8071
  }
8144
- function certDir() {
8145
- return join5(homedir5(), ".hasna", "machines", "certs");
8072
+ function selectRouteHint(hints) {
8073
+ return [...hints].sort((left, right) => routeRank(left) - routeRank(right))[0] ?? null;
8146
8074
  }
8147
- function buildCertPlan(domains) {
8148
- if (domains.length === 0) {
8149
- throw new Error("At least one domain is required.");
8150
- }
8151
- const primary = domains[0];
8152
- const certPath = join5(certDir(), `${primary}.pem`);
8153
- const keyPath = join5(certDir(), `${primary}-key.pem`);
8154
- const steps = [];
8155
- if (platform2() === "darwin") {
8156
- steps.push({
8157
- id: "mkcert-install-macos",
8158
- title: "Install mkcert on macOS",
8159
- command: "brew install mkcert nss",
8160
- manager: "brew"
8161
- });
8162
- } else {
8163
- steps.push({
8164
- id: "mkcert-install-linux",
8165
- title: "Install mkcert on Linux",
8166
- command: "sudo apt-get update && sudo apt-get install -y mkcert libnss3-tools",
8167
- manager: "apt",
8168
- privileged: true
8169
- });
8170
- }
8171
- steps.push({
8172
- id: "mkcert-local-ca",
8173
- title: "Install local mkcert CA",
8174
- command: "mkcert -install",
8175
- manager: "custom"
8176
- }, {
8177
- id: "mkcert-issue",
8178
- title: `Issue certificate for ${domains.join(", ")}`,
8179
- command: `mkdir -p ${quote3(certDir())} && mkcert -cert-file ${quote3(certPath)} -key-file ${quote3(keyPath)} ${domains.map((domain) => quote3(domain)).join(" ")}`,
8180
- manager: "custom"
8075
+ function buildEntry(input) {
8076
+ const manifest = input.manifest;
8077
+ const peer = input.peer;
8078
+ const hints = routeHints({
8079
+ machineId: input.machineId,
8080
+ localMachineId: input.localMachineId,
8081
+ manifest,
8082
+ peer
8181
8083
  });
8084
+ const selectedRoute = selectRouteHint(hints);
8085
+ const route = selectedRoute?.kind === "ssh" ? "ssh" : selectedRoute?.kind ?? "unknown";
8182
8086
  return {
8183
- machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
8184
- mode: "plan",
8185
- steps,
8186
- executed: 0
8087
+ machine_id: input.machineId,
8088
+ hostname: manifest?.hostname ?? peer?.HostName ?? null,
8089
+ platform: manifest?.platform ?? (peer?.OS ? normalizePlatform2(peer.OS) : null),
8090
+ os: peer?.OS ?? null,
8091
+ user: typeof manifest?.metadata?.user === "string" ? manifest.metadata.user : null,
8092
+ workspace_path: manifest?.workspacePath ?? null,
8093
+ manifest_declared: Boolean(manifest),
8094
+ heartbeat_status: input.heartbeat?.status ?? "unknown",
8095
+ last_heartbeat_at: input.heartbeat?.updated_at ?? null,
8096
+ tailscale: {
8097
+ dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
8098
+ ips: peer?.TailscaleIPs ?? [],
8099
+ online: peer?.Online ?? null,
8100
+ active: peer?.Active ?? null,
8101
+ last_seen: peer?.LastSeen ?? null
8102
+ },
8103
+ ssh: {
8104
+ address: manifest?.sshAddress ?? null,
8105
+ route,
8106
+ command_target: selectedRoute?.target ?? null
8107
+ },
8108
+ route_hints: hints,
8109
+ tags: manifest?.tags ?? [],
8110
+ metadata: manifest?.metadata ?? {}
8187
8111
  };
8188
8112
  }
8189
- function runCertPlan(domains, options = {}) {
8190
- const plan = buildCertPlan(domains);
8191
- if (!options.apply)
8192
- return plan;
8193
- if (!options.yes) {
8194
- throw new Error("Certificate generation requires --yes.");
8195
- }
8196
- let executed = 0;
8197
- for (const step of plan.steps) {
8198
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
8199
- stdout: "pipe",
8200
- stderr: "pipe",
8201
- env: process.env
8113
+ function discoverMachineTopology(options = {}) {
8114
+ const now2 = options.now ?? new Date;
8115
+ const runner = options.runner ?? defaultRunner;
8116
+ const warnings = [];
8117
+ const manifest = readManifest();
8118
+ const heartbeats = listHeartbeats();
8119
+ const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
8120
+ const localMachineId = getLocalMachineId();
8121
+ const peers = options.includeTailscale === false ? new Map : loadTailscalePeers(runner, warnings);
8122
+ const machineIds = new Set([
8123
+ localMachineId,
8124
+ ...manifest.machines.map((machine) => machine.id),
8125
+ ...heartbeats.map((heartbeat) => heartbeat.machine_id),
8126
+ ...peers.keys()
8127
+ ]);
8128
+ const manifestById = new Map(manifest.machines.map((machine) => [machine.id, machine]));
8129
+ const machines = [...machineIds].sort().map((machineId) => {
8130
+ const manifestMachine = manifestById.get(machineId);
8131
+ return buildEntry({
8132
+ machineId,
8133
+ localMachineId,
8134
+ manifest: manifestMachine,
8135
+ peer: findTailscalePeer(manifestMachine ?? null, machineId, peers),
8136
+ heartbeat: heartbeatByMachine.get(machineId)
8202
8137
  });
8203
- if (result.exitCode !== 0) {
8204
- throw new Error(`Certificate step failed (${step.id}): ${result.stderr.toString().trim()}`);
8205
- }
8206
- executed += 1;
8207
- }
8138
+ });
8208
8139
  return {
8209
- machineId: plan.machineId,
8210
- mode: "apply",
8211
- steps: plan.steps,
8212
- executed
8140
+ schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8141
+ package: {
8142
+ name: MACHINES_PACKAGE_NAME,
8143
+ version: getPackageVersion()
8144
+ },
8145
+ capabilities: getMachinesConsumerCapabilities(),
8146
+ generated_at: now2.toISOString(),
8147
+ local_machine_id: localMachineId,
8148
+ local_hostname: hostname3(),
8149
+ current_platform: normalizePlatform2(),
8150
+ manifest_path_known: existsSync5(getManifestPath()),
8151
+ machines,
8152
+ warnings
8213
8153
  };
8214
8154
  }
8215
-
8216
- // src/commands/dns.ts
8217
- init_paths();
8218
- import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
8219
- import { join as join6 } from "path";
8220
- function getDnsPath() {
8221
- return join6(getDataDir(), "dns.json");
8222
- }
8223
- function readMappings() {
8224
- const path = getDnsPath();
8225
- if (!existsSync5(path))
8226
- return [];
8227
- return JSON.parse(readFileSync3(path, "utf8"));
8228
- }
8229
- function writeMappings(mappings) {
8230
- const path = getDnsPath();
8231
- ensureParentDir(path);
8232
- writeFileSync2(path, `${JSON.stringify(mappings, null, 2)}
8233
- `, "utf8");
8234
- return path;
8235
- }
8236
- function addDomainMapping(domain, port, targetHost = "127.0.0.1") {
8237
- const mappings = readMappings().filter((entry) => entry.domain !== domain);
8238
- mappings.push({ domain, port, targetHost });
8239
- writeMappings(mappings);
8240
- return mappings.sort((left, right) => left.domain.localeCompare(right.domain));
8155
+ function normalizeMachineAlias(value) {
8156
+ return value.trim().replace(/\.$/, "").toLowerCase();
8241
8157
  }
8242
- function listDomainMappings() {
8243
- return readMappings().sort((left, right) => left.domain.localeCompare(right.domain));
8158
+ function routeTargetMatches(machine, requested) {
8159
+ const normalized = normalizeMachineAlias(requested);
8160
+ const values = [
8161
+ machine.ssh.address,
8162
+ machine.ssh.command_target,
8163
+ machine.tailscale.dns_name,
8164
+ machine.tailscale.dns_name?.split(".")[0],
8165
+ ...machine.tailscale.ips,
8166
+ ...machine.route_hints.map((hint) => hint.target),
8167
+ ...machine.route_hints.map((hint) => hint.target.split("@").pop() ?? hint.target)
8168
+ ].filter((value) => Boolean(value));
8169
+ return values.some((value) => normalizeMachineAlias(value) === normalized);
8244
8170
  }
8245
- function renderDomainMapping(domain) {
8246
- const entry = readMappings().find((mapping) => mapping.domain === domain);
8247
- if (!entry) {
8248
- throw new Error(`Domain mapping not found: ${domain}`);
8171
+ function findRouteMachine(topology, requestedMachineId) {
8172
+ const requested = normalizeMachineAlias(requestedMachineId);
8173
+ if (requested === "local" || requested === "localhost" || requested === normalizeMachineAlias(hostname3()) || requested === normalizeMachineAlias(topology.local_machine_id)) {
8174
+ return {
8175
+ machine: topology.machines.find((machine) => machine.machine_id === topology.local_machine_id) ?? null,
8176
+ matchedBy: "local_alias"
8177
+ };
8249
8178
  }
8250
- return {
8251
- hostsEntry: `${entry.targetHost} ${entry.domain}`,
8252
- caddySnippet: `${entry.domain} {
8253
- reverse_proxy 127.0.0.1:${entry.port}
8254
- tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
8255
- }`,
8256
- certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
8257
- keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
8258
- };
8179
+ const machineIdMatch = topology.machines.find((machine) => normalizeMachineAlias(machine.machine_id) === requested);
8180
+ if (machineIdMatch)
8181
+ return { machine: machineIdMatch, matchedBy: "machine_id" };
8182
+ const hostnameMatch = topology.machines.find((machine) => machine.hostname && normalizeMachineAlias(machine.hostname) === requested);
8183
+ if (hostnameMatch)
8184
+ return { machine: hostnameMatch, matchedBy: "hostname" };
8185
+ const tailscaleMatch = topology.machines.find((machine) => {
8186
+ if (!machine.tailscale.dns_name)
8187
+ return false;
8188
+ const dns = normalizeMachineAlias(machine.tailscale.dns_name);
8189
+ return dns === requested || dns.split(".")[0] === requested;
8190
+ });
8191
+ if (tailscaleMatch)
8192
+ return { machine: tailscaleMatch, matchedBy: "tailscale" };
8193
+ const routeMatch = topology.machines.find((machine) => routeTargetMatches(machine, requestedMachineId));
8194
+ if (routeMatch)
8195
+ return { machine: routeMatch, matchedBy: "route_target" };
8196
+ return { machine: null, matchedBy: null };
8259
8197
  }
8260
-
8261
- // src/commands/diff.ts
8262
- function packageNames(machine) {
8263
- return (machine.packages || []).map((pkg) => pkg.name).sort();
8198
+ function routeConfidence(input) {
8199
+ if (input.matchedBy === "local_alias")
8200
+ return "exact";
8201
+ if (input.hint?.kind === "local")
8202
+ return "exact";
8203
+ if (input.hint?.reachable === true)
8204
+ return "high";
8205
+ if (input.machine.manifest_declared && (input.hint?.kind === "ssh" || input.hint?.kind === "lan"))
8206
+ return "medium";
8207
+ if (input.hint)
8208
+ return "low";
8209
+ return "none";
8264
8210
  }
8265
- function fileTargets(machine) {
8266
- return (machine.files || []).map((file) => `${file.source}->${file.target}`).sort();
8211
+ function addMilliseconds(date, milliseconds) {
8212
+ return new Date(date.getTime() + milliseconds).toISOString();
8267
8213
  }
8268
- function diffMachines(leftMachineId, rightMachineId) {
8269
- const left = getManifestMachine(leftMachineId);
8270
- if (!left) {
8271
- throw new Error(`Machine not found in manifest: ${leftMachineId}`);
8272
- }
8273
- const right = rightMachineId ? getManifestMachine(rightMachineId) : detectCurrentMachineManifest();
8274
- if (!right) {
8275
- throw new Error(`Machine not found in manifest: ${rightMachineId}`);
8214
+ function routeAuthority(input) {
8215
+ if (!input.machine)
8216
+ return "unresolved";
8217
+ if (input.matchedBy === "fallback")
8218
+ return "fallback";
8219
+ if (input.selectedHint?.kind === "local")
8220
+ return "live_topology";
8221
+ if (input.selectedHint?.kind === "tailscale" || input.machine.tailscale.online !== null)
8222
+ return "live_topology";
8223
+ if (input.machine.manifest_declared)
8224
+ return "manifest";
8225
+ return "open-machines";
8226
+ }
8227
+ function workspaceAuthority(paths) {
8228
+ const sources = [paths.workspace_root.source, paths.project_root.source, paths.open_files_root.source];
8229
+ if (sources.some((source) => source === "argument"))
8230
+ return "argument";
8231
+ if (sources.some((source) => source === "manifest_metadata"))
8232
+ return "manifest_metadata";
8233
+ if (sources.some((source) => source === "manifest"))
8234
+ return "manifest";
8235
+ if (sources.some((source) => source === "inferred"))
8236
+ return "inferred";
8237
+ if (sources.every((source) => source === "unresolved"))
8238
+ return "unresolved";
8239
+ return "open-machines";
8240
+ }
8241
+ function cacheability(input) {
8242
+ const ttlMs = input.ttlMs === undefined ? DEFAULT_MACHINE_RESOLVER_TTL_MS : input.ttlMs;
8243
+ const expiresAt = typeof ttlMs === "number" && ttlMs > 0 ? addMilliseconds(input.observedAt, ttlMs) : null;
8244
+ const stale = expiresAt ? input.now.getTime() > new Date(expiresAt).getTime() : false;
8245
+ const confidenceCacheable = input.confidence !== "none" && input.confidence !== "low";
8246
+ const cacheable = input.ok && confidenceCacheable && !stale && input.authority !== "unresolved";
8247
+ const reasons = [...input.reasons];
8248
+ if (!input.ok)
8249
+ reasons.push("resolver_not_ok");
8250
+ if (!confidenceCacheable)
8251
+ reasons.push(`low_confidence:${input.confidence}`);
8252
+ if (stale)
8253
+ reasons.push("stale");
8254
+ if (input.authority === "unresolved")
8255
+ reasons.push("unresolved_authority");
8256
+ return {
8257
+ observed_at: input.observedAt.toISOString(),
8258
+ verified_at: input.ok ? input.now.toISOString() : null,
8259
+ expires_at: expiresAt,
8260
+ ttl_ms: typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : null,
8261
+ source_authority: input.authority,
8262
+ confidence: input.confidence,
8263
+ cacheable,
8264
+ stale,
8265
+ reasons: [...new Set(reasons)].sort()
8266
+ };
8267
+ }
8268
+ function resolveMachineRoute(machineId, options = {}) {
8269
+ const now2 = options.now ?? new Date;
8270
+ const topology = options.topology ?? discoverMachineTopology(options);
8271
+ const warnings = [...topology.warnings];
8272
+ const { machine, matchedBy } = findRouteMachine(topology, machineId);
8273
+ const generatedAt = now2.toISOString();
8274
+ if (!machine) {
8275
+ warnings.push(`machine_not_found:${machineId}`);
8276
+ return {
8277
+ schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8278
+ package: { name: MACHINES_PACKAGE_NAME, version: getPackageVersion() },
8279
+ ok: false,
8280
+ machine_id: null,
8281
+ requested_machine_id: machineId,
8282
+ generated_at: generatedAt,
8283
+ route: "unknown",
8284
+ source: "unknown",
8285
+ target: null,
8286
+ command_target: null,
8287
+ confidence: "none",
8288
+ local: false,
8289
+ evidence: {
8290
+ topology: true,
8291
+ matched_by: null,
8292
+ manifest_declared: null,
8293
+ heartbeat_status: null,
8294
+ tailscale_online: null,
8295
+ selected_hint: null
8296
+ },
8297
+ cacheability: cacheability({
8298
+ ok: false,
8299
+ observedAt: now2,
8300
+ now: now2,
8301
+ ttlMs: options.resolverTtlMs,
8302
+ authority: "unresolved",
8303
+ confidence: "none",
8304
+ reasons: [`machine_not_found:${machineId}`]
8305
+ }),
8306
+ warnings
8307
+ };
8276
8308
  }
8277
- const changedFields = [
8278
- left.platform !== right.platform ? "platform" : null,
8279
- left.connection !== right.connection ? "connection" : null,
8280
- left.workspacePath !== right.workspacePath ? "workspacePath" : null,
8281
- left.bunPath !== right.bunPath ? "bunPath" : null
8282
- ].filter(Boolean);
8283
- const leftPackages = new Set(packageNames(left));
8284
- const rightPackages = new Set(packageNames(right));
8285
- const leftFiles = new Set(fileTargets(left));
8286
- const rightFiles = new Set(fileTargets(right));
8309
+ const selectedHint = selectRouteHint(machine.route_hints);
8310
+ const route = selectedHint?.kind ?? machine.ssh.route ?? "unknown";
8311
+ const local = route === "local" || machine.machine_id === topology.local_machine_id;
8312
+ const confidence = routeConfidence({ machine, hint: selectedHint, matchedBy });
8313
+ const ok = Boolean(selectedHint?.target);
8287
8314
  return {
8288
- leftMachineId: left.id,
8289
- rightMachineId: right.id,
8290
- changedFields,
8291
- missingPackages: {
8292
- leftOnly: [...leftPackages].filter((pkg) => !rightPackages.has(pkg)),
8293
- rightOnly: [...rightPackages].filter((pkg) => !leftPackages.has(pkg))
8315
+ schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8316
+ package: topology.package,
8317
+ ok,
8318
+ machine_id: machine.machine_id,
8319
+ requested_machine_id: machineId,
8320
+ generated_at: generatedAt,
8321
+ route,
8322
+ source: route,
8323
+ target: selectedHint?.target ?? null,
8324
+ command_target: selectedHint?.target ?? null,
8325
+ confidence,
8326
+ local,
8327
+ evidence: {
8328
+ topology: true,
8329
+ matched_by: matchedBy,
8330
+ manifest_declared: machine.manifest_declared,
8331
+ heartbeat_status: machine.heartbeat_status,
8332
+ tailscale_online: machine.tailscale.online,
8333
+ selected_hint: selectedHint
8294
8334
  },
8295
- missingFiles: {
8296
- leftOnly: [...leftFiles].filter((file) => !rightFiles.has(file)),
8297
- rightOnly: [...rightFiles].filter((file) => !leftFiles.has(file))
8298
- }
8335
+ cacheability: cacheability({
8336
+ ok,
8337
+ observedAt: now2,
8338
+ now: now2,
8339
+ ttlMs: options.resolverTtlMs,
8340
+ authority: routeAuthority({ machine, selectedHint, matchedBy }),
8341
+ confidence,
8342
+ reasons: selectedHint ? [] : ["route_target_unresolved"]
8343
+ }),
8344
+ warnings
8299
8345
  };
8300
8346
  }
8301
-
8302
- // src/remote.ts
8303
- init_db();
8304
- import { spawnSync as spawnSync2 } from "child_process";
8305
- import { hostname as hostname5 } from "os";
8306
-
8307
- // src/topology.ts
8308
- init_db();
8309
- import { existsSync as existsSync6 } from "fs";
8310
- import { arch as arch2, hostname as hostname4, platform as platform3, userInfo as userInfo2 } from "os";
8311
- import { spawnSync } from "child_process";
8312
- init_paths();
8313
- var MACHINES_CONSUMER_CONTRACT_VERSION = 1;
8314
- var MACHINES_PACKAGE_NAME = "@hasna/machines";
8315
- var DEFAULT_MACHINE_RESOLVER_TTL_MS = 24 * 60 * 60 * 1000;
8316
- var MACHINES_CONSUMER_CAPABILITIES = {
8317
- topology: true,
8318
- compatibility: true,
8319
- route_resolution: true,
8320
- cli_json_fallback: true,
8321
- workspace_path_mapping: true,
8322
- workspace_diagnostics: true,
8323
- schema_artifacts: true,
8324
- cacheability_metadata: true,
8325
- resolver_snapshots: true,
8326
- field_capability_descriptors: true
8327
- };
8328
- function getMachinesConsumerCapabilities() {
8329
- return { ...MACHINES_CONSUMER_CAPABILITIES };
8347
+ function isRecord(value) {
8348
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8330
8349
  }
8331
- function normalizePlatform2(value = platform3()) {
8332
- const normalized = value.toLowerCase();
8333
- if (normalized === "darwin" || normalized === "macos")
8334
- return "macos";
8335
- if (normalized === "win32" || normalized === "windows")
8336
- return "windows";
8337
- if (normalized === "linux")
8338
- return "linux";
8339
- return value;
8350
+ function metadataString(metadata, keys) {
8351
+ for (const key of keys) {
8352
+ const value = metadata[key];
8353
+ if (typeof value === "string" && value.trim())
8354
+ return value.trim();
8355
+ }
8356
+ return null;
8340
8357
  }
8341
- function defaultRunner(command) {
8342
- const result = spawnSync("bash", ["-c", command], {
8343
- encoding: "utf8",
8344
- env: process.env
8345
- });
8346
- return {
8347
- stdout: result.stdout || "",
8348
- stderr: result.stderr || "",
8349
- exitCode: result.status ?? 1
8350
- };
8351
- }
8352
- function hasCommand(command, runner) {
8353
- return runner(`command -v ${command} >/dev/null 2>&1`).exitCode === 0;
8354
- }
8355
- function parseTailscaleStatus(raw) {
8356
- try {
8357
- const parsed = JSON.parse(raw);
8358
- if (!parsed || typeof parsed !== "object")
8359
- return null;
8360
- return parsed;
8361
- } catch {
8362
- return null;
8358
+ function metadataBoolean(metadata, keys) {
8359
+ for (const key of keys) {
8360
+ const value = metadata[key];
8361
+ if (typeof value === "boolean")
8362
+ return value;
8363
8363
  }
8364
+ return null;
8364
8365
  }
8365
- function loadTailscalePeers(runner, warnings) {
8366
- const peers = new Map;
8367
- if (!hasCommand("tailscale", runner)) {
8368
- warnings.push("tailscale_not_available");
8369
- return peers;
8370
- }
8371
- const result = runner("tailscale status --json");
8372
- if (result.exitCode !== 0) {
8373
- warnings.push(`tailscale_status_failed:${result.stderr.trim() || result.exitCode}`);
8374
- return peers;
8375
- }
8376
- const status = parseTailscaleStatus(result.stdout);
8377
- if (!status) {
8378
- warnings.push("tailscale_status_invalid_json");
8379
- return peers;
8366
+ function metadataStringArray(metadata, keys) {
8367
+ for (const key of keys) {
8368
+ const value = metadata[key];
8369
+ if (Array.isArray(value))
8370
+ return value.filter((entry) => typeof entry === "string");
8380
8371
  }
8381
- const addPeer = (peer) => {
8382
- if (!peer)
8383
- return;
8384
- const id = peer.HostName || peer.DNSName?.split(".")[0];
8385
- if (id)
8386
- peers.set(id, peer);
8387
- };
8388
- addPeer(status.Self);
8389
- for (const peer of Object.values(status.Peer ?? {}))
8390
- addPeer(peer);
8391
- return peers;
8392
- }
8393
- function machineKeys(machine) {
8394
- return [
8395
- machine.id,
8396
- machine.hostname,
8397
- machine.tailscaleName?.split(".")[0],
8398
- machine.tailscaleName,
8399
- machine.sshAddress?.split("@").pop()
8400
- ].filter((value) => Boolean(value));
8372
+ return [];
8401
8373
  }
8402
- function findTailscalePeer(machine, machineId, peers) {
8403
- if (machine) {
8404
- for (const key of machineKeys(machine)) {
8405
- const peer = peers.get(key) ?? peers.get(key.replace(/\.$/, ""));
8406
- if (peer)
8407
- return peer;
8374
+ function readMappedPath(input) {
8375
+ for (const containerName of input.containers) {
8376
+ const container = input.metadata[containerName];
8377
+ if (!isRecord(container))
8378
+ continue;
8379
+ for (const key of input.keys) {
8380
+ const value = container[key];
8381
+ if (typeof value === "string" && value.trim())
8382
+ return value.trim();
8383
+ if (isRecord(value)) {
8384
+ const path = metadataString(value, ["path", "root", "workspacePath", "workspace_path"]);
8385
+ if (path)
8386
+ return path;
8387
+ }
8408
8388
  }
8409
8389
  }
8410
- return peers.get(machineId) ?? null;
8390
+ return null;
8411
8391
  }
8412
- function envReachableHosts() {
8413
- const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
8414
- return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
8392
+ function trimTrailingSlash(value) {
8393
+ return value.replace(/\/+$/, "");
8415
8394
  }
8416
- function manifestHostReachable(target) {
8417
- const overrides = envReachableHosts();
8418
- if (overrides.size === 0)
8419
- return null;
8420
- return overrides.has(target);
8395
+ function joinPath(left, right) {
8396
+ return `${trimTrailingSlash(left)}/${right.replace(/^\/+/, "")}`;
8421
8397
  }
8422
- function routeHints(input) {
8423
- const hints = [];
8424
- if (input.machineId === input.localMachineId) {
8425
- hints.push({ kind: "local", target: "localhost", reachable: true });
8426
- }
8427
- if (input.manifest?.sshAddress) {
8428
- hints.push({ kind: "ssh", target: input.manifest.sshAddress, reachable: manifestHostReachable(input.manifest.sshAddress) });
8429
- }
8430
- if (input.manifest?.hostname) {
8431
- hints.push({ kind: "lan", target: input.manifest.hostname, reachable: manifestHostReachable(input.manifest.hostname) });
8432
- }
8433
- const tailscaleTarget = input.manifest?.tailscaleName ?? input.peer?.DNSName ?? input.peer?.TailscaleIPs?.[0];
8434
- if (tailscaleTarget) {
8435
- hints.push({ kind: "tailscale", target: tailscaleTarget.replace(/\.$/, ""), reachable: input.peer?.Online ?? null });
8398
+ function inferRepoRoot(workspaceRoot, repoName) {
8399
+ if (!workspaceRoot || !repoName)
8400
+ return null;
8401
+ const root = trimTrailingSlash(workspaceRoot);
8402
+ if (root.endsWith(`/${repoName}`) || root === repoName)
8403
+ return root;
8404
+ if (root.endsWith("/workspace") || root.endsWith("/Workspace")) {
8405
+ return joinPath(root, `hasna/opensource/${repoName}`);
8436
8406
  }
8437
- return hints;
8407
+ return joinPath(root, repoName);
8438
8408
  }
8439
- function routeRank(hint) {
8440
- if (hint.kind === "local")
8441
- return 0;
8442
- if (hint.reachable === true && hint.kind === "ssh")
8443
- return 1;
8444
- if (hint.reachable === true && hint.kind === "lan")
8445
- return 2;
8446
- if (hint.reachable === true && hint.kind === "tailscale")
8447
- return 3;
8448
- if (hint.reachable === false)
8449
- return 8;
8450
- if (hint.kind === "ssh")
8451
- return 4;
8452
- if (hint.kind === "lan")
8453
- return 5;
8454
- if (hint.kind === "tailscale")
8455
- return 6;
8456
- return 9;
8409
+ function shellQuote(value) {
8410
+ return `'${value.replace(/'/g, "'\\''")}'`;
8457
8411
  }
8458
- function selectRouteHint(hints) {
8459
- return [...hints].sort((left, right) => routeRank(left) - routeRank(right))[0] ?? null;
8412
+ function shellCommand(command) {
8413
+ return command.map(shellQuote).join(" ");
8460
8414
  }
8461
- function buildEntry(input) {
8462
- const manifest = input.manifest;
8463
- const peer = input.peer;
8464
- const hints = routeHints({
8465
- machineId: input.machineId,
8466
- localMachineId: input.localMachineId,
8467
- manifest,
8468
- peer
8469
- });
8470
- const selectedRoute = selectRouteHint(hints);
8471
- const route = selectedRoute?.kind === "ssh" ? "ssh" : selectedRoute?.kind ?? "unknown";
8472
- return {
8473
- machine_id: input.machineId,
8474
- hostname: manifest?.hostname ?? peer?.HostName ?? null,
8475
- platform: manifest?.platform ?? (peer?.OS ? normalizePlatform2(peer.OS) : null),
8476
- os: peer?.OS ?? null,
8477
- user: typeof manifest?.metadata?.user === "string" ? manifest.metadata.user : null,
8478
- workspace_path: manifest?.workspacePath ?? null,
8479
- manifest_declared: Boolean(manifest),
8480
- heartbeat_status: input.heartbeat?.status ?? "unknown",
8481
- last_heartbeat_at: input.heartbeat?.updated_at ?? null,
8482
- tailscale: {
8483
- dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
8484
- ips: peer?.TailscaleIPs ?? [],
8485
- online: peer?.Online ?? null,
8486
- active: peer?.Active ?? null,
8487
- last_seen: peer?.LastSeen ?? null
8488
- },
8489
- ssh: {
8490
- address: manifest?.sshAddress ?? null,
8491
- route,
8492
- command_target: selectedRoute?.target ?? null
8493
- },
8494
- route_hints: hints,
8495
- tags: manifest?.tags ?? [],
8496
- metadata: manifest?.metadata ?? {}
8497
- };
8415
+ function canCheckPathForMachine(machine, localMachineId) {
8416
+ if (!machine)
8417
+ return false;
8418
+ if (machine.machine_id === localMachineId)
8419
+ return true;
8420
+ return machine.route_hints.some((hint) => hint.kind === "local");
8498
8421
  }
8499
- function discoverMachineTopology(options = {}) {
8500
- const now2 = options.now ?? new Date;
8501
- const runner = options.runner ?? defaultRunner;
8502
- const warnings = [];
8503
- const manifest = readManifest();
8504
- const heartbeats = listHeartbeats();
8505
- const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
8506
- const localMachineId = getLocalMachineId();
8507
- const peers = options.includeTailscale === false ? new Map : loadTailscalePeers(runner, warnings);
8508
- const machineIds = new Set([
8509
- localMachineId,
8510
- ...manifest.machines.map((machine) => machine.id),
8511
- ...heartbeats.map((heartbeat) => heartbeat.machine_id),
8512
- ...peers.keys()
8513
- ]);
8514
- const manifestById = new Map(manifest.machines.map((machine) => [machine.id, machine]));
8515
- const machines = [...machineIds].sort().map((machineId) => {
8516
- const manifestMachine = manifestById.get(machineId);
8517
- return buildEntry({
8518
- machineId,
8519
- localMachineId,
8520
- manifest: manifestMachine,
8521
- peer: findTailscalePeer(manifestMachine ?? null, machineId, peers),
8522
- heartbeat: heartbeatByMachine.get(machineId)
8523
- });
8524
- });
8422
+ function checkedPathExists(path, check) {
8423
+ if (!path || !check)
8424
+ return null;
8425
+ return existsSync5(path);
8426
+ }
8427
+ function repairHint(input) {
8428
+ const command = [
8429
+ "machines",
8430
+ "workspace",
8431
+ "repair",
8432
+ "--machine",
8433
+ input.machineId,
8434
+ "--project",
8435
+ input.projectId
8436
+ ];
8437
+ if (input.repoName)
8438
+ command.push("--repo", input.repoName);
8439
+ if (input.openFilesRepoName)
8440
+ command.push("--open-files-repo", input.openFilesRepoName);
8441
+ command.push("--json");
8442
+ const applyCommand = [...command.slice(0, -1), "--apply", "--json"];
8525
8443
  return {
8526
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8527
- package: {
8528
- name: MACHINES_PACKAGE_NAME,
8529
- version: getPackageVersion()
8530
- },
8531
- capabilities: getMachinesConsumerCapabilities(),
8532
- generated_at: now2.toISOString(),
8533
- local_machine_id: localMachineId,
8534
- local_hostname: hostname4(),
8535
- current_platform: normalizePlatform2(),
8536
- manifest_path_known: existsSync6(getManifestPath()),
8537
- machines,
8538
- warnings
8444
+ id: `repair:${input.machineId}:${input.projectId}`,
8445
+ reason: input.reason,
8446
+ command,
8447
+ shell_command: shellCommand(command),
8448
+ apply_command: applyCommand,
8449
+ apply_shell_command: shellCommand(applyCommand)
8539
8450
  };
8540
8451
  }
8541
- function normalizeMachineAlias(value) {
8542
- return value.trim().replace(/\.$/, "").toLowerCase();
8543
- }
8544
- function routeTargetMatches(machine, requested) {
8545
- const normalized = normalizeMachineAlias(requested);
8546
- const values = [
8547
- machine.ssh.address,
8548
- machine.ssh.command_target,
8549
- machine.tailscale.dns_name,
8550
- machine.tailscale.dns_name?.split(".")[0],
8551
- ...machine.tailscale.ips,
8552
- ...machine.route_hints.map((hint) => hint.target),
8553
- ...machine.route_hints.map((hint) => hint.target.split("@").pop() ?? hint.target)
8554
- ].filter((value) => Boolean(value));
8555
- return values.some((value) => normalizeMachineAlias(value) === normalized);
8556
- }
8557
- function findRouteMachine(topology, requestedMachineId) {
8558
- const requested = normalizeMachineAlias(requestedMachineId);
8559
- if (requested === "local" || requested === "localhost" || requested === normalizeMachineAlias(hostname4()) || requested === normalizeMachineAlias(topology.local_machine_id)) {
8452
+ function pathDiagnostic(input) {
8453
+ if (!input.path.path) {
8560
8454
  return {
8561
- machine: topology.machines.find((machine) => machine.machine_id === topology.local_machine_id) ?? null,
8562
- matchedBy: "local_alias"
8455
+ id: input.id,
8456
+ status: "missing",
8457
+ severity: input.required ? "fail" : "warn",
8458
+ message: `${input.label} is unresolved.`,
8459
+ path: null,
8460
+ source: input.path.source,
8461
+ path_exists: null
8563
8462
  };
8564
8463
  }
8565
- const machineIdMatch = topology.machines.find((machine) => normalizeMachineAlias(machine.machine_id) === requested);
8566
- if (machineIdMatch)
8567
- return { machine: machineIdMatch, matchedBy: "machine_id" };
8568
- const hostnameMatch = topology.machines.find((machine) => machine.hostname && normalizeMachineAlias(machine.hostname) === requested);
8569
- if (hostnameMatch)
8570
- return { machine: hostnameMatch, matchedBy: "hostname" };
8571
- const tailscaleMatch = topology.machines.find((machine) => {
8572
- if (!machine.tailscale.dns_name)
8573
- return false;
8574
- const dns = normalizeMachineAlias(machine.tailscale.dns_name);
8575
- return dns === requested || dns.split(".")[0] === requested;
8576
- });
8577
- if (tailscaleMatch)
8578
- return { machine: tailscaleMatch, matchedBy: "tailscale" };
8579
- const routeMatch = topology.machines.find((machine) => routeTargetMatches(machine, requestedMachineId));
8580
- if (routeMatch)
8581
- return { machine: routeMatch, matchedBy: "route_target" };
8582
- return { machine: null, matchedBy: null };
8583
- }
8584
- function routeConfidence(input) {
8585
- if (input.matchedBy === "local_alias")
8586
- return "exact";
8587
- if (input.hint?.kind === "local")
8588
- return "exact";
8589
- if (input.hint?.reachable === true)
8590
- return "high";
8591
- if (input.machine.manifest_declared && (input.hint?.kind === "ssh" || input.hint?.kind === "lan"))
8592
- return "medium";
8593
- if (input.hint)
8594
- return "low";
8595
- return "none";
8596
- }
8597
- function addMilliseconds(date, milliseconds) {
8598
- return new Date(date.getTime() + milliseconds).toISOString();
8599
- }
8600
- function routeAuthority(input) {
8601
- if (!input.machine)
8602
- return "unresolved";
8603
- if (input.matchedBy === "fallback")
8604
- return "fallback";
8605
- if (input.selectedHint?.kind === "local")
8606
- return "live_topology";
8607
- if (input.selectedHint?.kind === "tailscale" || input.machine.tailscale.online !== null)
8608
- return "live_topology";
8609
- if (input.machine.manifest_declared)
8610
- return "manifest";
8611
- return "open-machines";
8612
- }
8613
- function workspaceAuthority(paths) {
8614
- const sources = [paths.workspace_root.source, paths.project_root.source, paths.open_files_root.source];
8615
- if (sources.some((source) => source === "argument"))
8616
- return "argument";
8617
- if (sources.some((source) => source === "manifest_metadata"))
8618
- return "manifest_metadata";
8619
- if (sources.some((source) => source === "manifest"))
8620
- return "manifest";
8621
- if (sources.some((source) => source === "inferred"))
8622
- return "inferred";
8623
- if (sources.every((source) => source === "unresolved"))
8624
- return "unresolved";
8625
- return "open-machines";
8626
- }
8627
- function cacheability(input) {
8628
- const ttlMs = input.ttlMs === undefined ? DEFAULT_MACHINE_RESOLVER_TTL_MS : input.ttlMs;
8629
- const expiresAt = typeof ttlMs === "number" && ttlMs > 0 ? addMilliseconds(input.observedAt, ttlMs) : null;
8630
- const stale = expiresAt ? input.now.getTime() > new Date(expiresAt).getTime() : false;
8631
- const confidenceCacheable = input.confidence !== "none" && input.confidence !== "low";
8632
- const cacheable = input.ok && confidenceCacheable && !stale && input.authority !== "unresolved";
8633
- const reasons = [...input.reasons];
8634
- if (!input.ok)
8635
- reasons.push("resolver_not_ok");
8636
- if (!confidenceCacheable)
8637
- reasons.push(`low_confidence:${input.confidence}`);
8638
- if (stale)
8639
- reasons.push("stale");
8640
- if (input.authority === "unresolved")
8641
- reasons.push("unresolved_authority");
8642
- return {
8643
- observed_at: input.observedAt.toISOString(),
8644
- verified_at: input.ok ? input.now.toISOString() : null,
8645
- expires_at: expiresAt,
8646
- ttl_ms: typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : null,
8647
- source_authority: input.authority,
8648
- confidence: input.confidence,
8649
- cacheable,
8650
- stale,
8651
- reasons: [...new Set(reasons)].sort()
8652
- };
8653
- }
8654
- function resolveMachineRoute(machineId, options = {}) {
8655
- const now2 = options.now ?? new Date;
8656
- const topology = options.topology ?? discoverMachineTopology(options);
8657
- const warnings = [...topology.warnings];
8658
- const { machine, matchedBy } = findRouteMachine(topology, machineId);
8659
- const generatedAt = now2.toISOString();
8660
- if (!machine) {
8661
- warnings.push(`machine_not_found:${machineId}`);
8464
+ if (input.pathExists === false) {
8662
8465
  return {
8663
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8664
- package: { name: MACHINES_PACKAGE_NAME, version: getPackageVersion() },
8665
- ok: false,
8666
- machine_id: null,
8667
- requested_machine_id: machineId,
8668
- generated_at: generatedAt,
8669
- route: "unknown",
8670
- source: "unknown",
8671
- target: null,
8672
- command_target: null,
8673
- confidence: "none",
8674
- local: false,
8675
- evidence: {
8676
- topology: true,
8677
- matched_by: null,
8678
- manifest_declared: null,
8679
- heartbeat_status: null,
8680
- tailscale_online: null,
8681
- selected_hint: null
8682
- },
8683
- cacheability: cacheability({
8684
- ok: false,
8685
- observedAt: now2,
8686
- now: now2,
8687
- ttlMs: options.resolverTtlMs,
8688
- authority: "unresolved",
8689
- confidence: "none",
8690
- reasons: [`machine_not_found:${machineId}`]
8691
- }),
8692
- warnings
8466
+ id: input.id,
8467
+ status: "stale",
8468
+ severity: "fail",
8469
+ message: `${input.label} points to a path that does not exist on this machine.`,
8470
+ path: input.path.path,
8471
+ source: input.path.source,
8472
+ path_exists: false
8473
+ };
8474
+ }
8475
+ if (input.path.source === "inferred") {
8476
+ return {
8477
+ id: input.id,
8478
+ status: "inferred",
8479
+ severity: "warn",
8480
+ message: `${input.label} was inferred from the workspace root; write an explicit manifest mapping for repeatable downstream sync.`,
8481
+ path: input.path.path,
8482
+ source: input.path.source,
8483
+ path_exists: input.pathExists
8693
8484
  };
8694
8485
  }
8695
- const selectedHint = selectRouteHint(machine.route_hints);
8696
- const route = selectedHint?.kind ?? machine.ssh.route ?? "unknown";
8697
- const local = route === "local" || machine.machine_id === topology.local_machine_id;
8698
- const confidence = routeConfidence({ machine, hint: selectedHint, matchedBy });
8699
- const ok = Boolean(selectedHint?.target);
8700
8486
  return {
8701
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8702
- package: topology.package,
8703
- ok,
8704
- machine_id: machine.machine_id,
8705
- requested_machine_id: machineId,
8706
- generated_at: generatedAt,
8707
- route,
8708
- source: route,
8709
- target: selectedHint?.target ?? null,
8710
- command_target: selectedHint?.target ?? null,
8711
- confidence,
8712
- local,
8713
- evidence: {
8714
- topology: true,
8715
- matched_by: matchedBy,
8716
- manifest_declared: machine.manifest_declared,
8717
- heartbeat_status: machine.heartbeat_status,
8718
- tailscale_online: machine.tailscale.online,
8719
- selected_hint: selectedHint
8720
- },
8721
- cacheability: cacheability({
8722
- ok,
8723
- observedAt: now2,
8724
- now: now2,
8725
- ttlMs: options.resolverTtlMs,
8726
- authority: routeAuthority({ machine, selectedHint, matchedBy }),
8727
- confidence,
8728
- reasons: selectedHint ? [] : ["route_target_unresolved"]
8729
- }),
8730
- warnings
8487
+ id: input.id,
8488
+ status: "ok",
8489
+ severity: "ok",
8490
+ message: `${input.label} is explicit enough for downstream sync.`,
8491
+ path: input.path.path,
8492
+ source: input.path.source,
8493
+ path_exists: input.pathExists
8731
8494
  };
8732
8495
  }
8733
- function isRecord(value) {
8734
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8735
- }
8736
- function metadataString(metadata, keys) {
8737
- for (const key of keys) {
8738
- const value = metadata[key];
8739
- if (typeof value === "string" && value.trim())
8740
- return value.trim();
8741
- }
8742
- return null;
8743
- }
8744
- function metadataBoolean(metadata, keys) {
8745
- for (const key of keys) {
8746
- const value = metadata[key];
8747
- if (typeof value === "boolean")
8748
- return value;
8749
- }
8750
- return null;
8751
- }
8752
- function metadataStringArray(metadata, keys) {
8753
- for (const key of keys) {
8754
- const value = metadata[key];
8755
- if (Array.isArray(value))
8756
- return value.filter((entry) => typeof entry === "string");
8757
- }
8758
- return [];
8759
- }
8760
- function readMappedPath(input) {
8761
- for (const containerName of input.containers) {
8762
- const container = input.metadata[containerName];
8763
- if (!isRecord(container))
8764
- continue;
8765
- for (const key of input.keys) {
8766
- const value = container[key];
8767
- if (typeof value === "string" && value.trim())
8768
- return value.trim();
8769
- if (isRecord(value)) {
8770
- const path = metadataString(value, ["path", "root", "workspacePath", "workspace_path"]);
8771
- if (path)
8772
- return path;
8773
- }
8774
- }
8775
- }
8776
- return null;
8777
- }
8778
- function trimTrailingSlash(value) {
8779
- return value.replace(/\/+$/, "");
8780
- }
8781
- function joinPath(left, right) {
8782
- return `${trimTrailingSlash(left)}/${right.replace(/^\/+/, "")}`;
8783
- }
8784
- function inferRepoRoot(workspaceRoot, repoName) {
8785
- if (!workspaceRoot || !repoName)
8786
- return null;
8787
- const root = trimTrailingSlash(workspaceRoot);
8788
- if (root.endsWith(`/${repoName}`) || root === repoName)
8789
- return root;
8790
- if (root.endsWith("/workspace") || root.endsWith("/Workspace")) {
8791
- return joinPath(root, `hasna/opensource/${repoName}`);
8792
- }
8793
- return joinPath(root, repoName);
8794
- }
8795
- function shellQuote(value) {
8796
- return `'${value.replace(/'/g, "'\\''")}'`;
8797
- }
8798
- function shellCommand(command) {
8799
- return command.map(shellQuote).join(" ");
8800
- }
8801
- function canCheckPathForMachine(machine, localMachineId) {
8802
- if (!machine)
8803
- return false;
8804
- if (machine.machine_id === localMachineId)
8805
- return true;
8806
- return machine.route_hints.some((hint) => hint.kind === "local");
8807
- }
8808
- function checkedPathExists(path, check) {
8809
- if (!path || !check)
8810
- return null;
8811
- return existsSync6(path);
8812
- }
8813
- function repairHint(input) {
8814
- const command = [
8815
- "machines",
8816
- "workspace",
8817
- "repair",
8818
- "--machine",
8819
- input.machineId,
8820
- "--project",
8821
- input.projectId
8822
- ];
8823
- if (input.repoName)
8824
- command.push("--repo", input.repoName);
8825
- if (input.openFilesRepoName)
8826
- command.push("--open-files-repo", input.openFilesRepoName);
8827
- command.push("--json");
8828
- const applyCommand = [...command.slice(0, -1), "--apply", "--json"];
8829
- return {
8830
- id: `repair:${input.machineId}:${input.projectId}`,
8831
- reason: input.reason,
8832
- command,
8833
- shell_command: shellCommand(command),
8834
- apply_command: applyCommand,
8835
- apply_shell_command: shellCommand(applyCommand)
8836
- };
8837
- }
8838
- function pathDiagnostic(input) {
8839
- if (!input.path.path) {
8840
- return {
8841
- id: input.id,
8842
- status: "missing",
8843
- severity: input.required ? "fail" : "warn",
8844
- message: `${input.label} is unresolved.`,
8845
- path: null,
8846
- source: input.path.source,
8847
- path_exists: null
8848
- };
8849
- }
8850
- if (input.pathExists === false) {
8851
- return {
8852
- id: input.id,
8853
- status: "stale",
8854
- severity: "fail",
8855
- message: `${input.label} points to a path that does not exist on this machine.`,
8856
- path: input.path.path,
8857
- source: input.path.source,
8858
- path_exists: false
8859
- };
8860
- }
8861
- if (input.path.source === "inferred") {
8862
- return {
8863
- id: input.id,
8864
- status: "inferred",
8865
- severity: "warn",
8866
- message: `${input.label} was inferred from the workspace root; write an explicit manifest mapping for repeatable downstream sync.`,
8867
- path: input.path.path,
8868
- source: input.path.source,
8869
- path_exists: input.pathExists
8870
- };
8871
- }
8872
- return {
8873
- id: input.id,
8874
- status: "ok",
8875
- severity: "ok",
8876
- message: `${input.label} is explicit enough for downstream sync.`,
8877
- path: input.path.path,
8878
- source: input.path.source,
8879
- path_exists: input.pathExists
8880
- };
8881
- }
8882
- function workspaceDiagnostics(input) {
8883
- const checkPaths = canCheckPathForMachine(input.machine, input.localMachineId);
8884
- const diagnostics = [];
8885
- if (!input.machine) {
8886
- diagnostics.push({
8887
- id: "manifest",
8888
- status: "missing_manifest",
8889
- severity: "fail",
8890
- message: "Machine is not present in topology or manifest.",
8891
- path: null,
8892
- source: "manifest",
8893
- path_exists: null
8894
- });
8895
- } else if (!input.resolution.evidence.manifest_declared) {
8896
- diagnostics.push({
8897
- id: "manifest",
8898
- status: "missing_manifest",
8899
- severity: "warn",
8900
- message: "Machine came from live topology but is not declared in the manifest.",
8901
- path: null,
8902
- source: "manifest",
8903
- path_exists: null
8904
- });
8496
+ function workspaceDiagnostics(input) {
8497
+ const checkPaths = canCheckPathForMachine(input.machine, input.localMachineId);
8498
+ const diagnostics = [];
8499
+ if (!input.machine) {
8500
+ diagnostics.push({
8501
+ id: "manifest",
8502
+ status: "missing_manifest",
8503
+ severity: "fail",
8504
+ message: "Machine is not present in topology or manifest.",
8505
+ path: null,
8506
+ source: "manifest",
8507
+ path_exists: null
8508
+ });
8509
+ } else if (!input.resolution.evidence.manifest_declared) {
8510
+ diagnostics.push({
8511
+ id: "manifest",
8512
+ status: "missing_manifest",
8513
+ severity: "warn",
8514
+ message: "Machine came from live topology but is not declared in the manifest.",
8515
+ path: null,
8516
+ source: "manifest",
8517
+ path_exists: null
8518
+ });
8905
8519
  }
8906
8520
  diagnostics.push(pathDiagnostic({
8907
8521
  id: "workspace_root",
@@ -9138,80 +8752,478 @@ function resolveMachineWorkspace(options) {
9138
8752
  }),
9139
8753
  warnings
9140
8754
  };
9141
- const diagnostics = workspaceDiagnostics({
9142
- machine,
9143
- localMachineId: topology.local_machine_id,
9144
- resolution,
9145
- openFilesRepoName
9146
- });
8755
+ const diagnostics = workspaceDiagnostics({
8756
+ machine,
8757
+ localMachineId: topology.local_machine_id,
8758
+ resolution,
8759
+ openFilesRepoName
8760
+ });
8761
+ return {
8762
+ ...resolution,
8763
+ diagnostics: diagnostics.diagnostics,
8764
+ repair_hints: diagnostics.repairHints
8765
+ };
8766
+ }
8767
+
8768
+ // src/commands/ssh.ts
8769
+ function shellQuote2(value) {
8770
+ return `'${value.replace(/'/g, "'\\''")}'`;
8771
+ }
8772
+ function resolveSshTarget(machineId, options = {}) {
8773
+ const resolved = resolveMachineRoute(machineId, options);
8774
+ if (!resolved.ok || !resolved.target) {
8775
+ throw new Error(`Machine route not found: ${machineId}`);
8776
+ }
8777
+ if (resolved.route !== "local" && resolved.route !== "lan" && resolved.route !== "tailscale" && resolved.route !== "ssh") {
8778
+ throw new Error(`Machine route is not SSH-capable: ${machineId}`);
8779
+ }
8780
+ return {
8781
+ machineId: resolved.machine_id ?? machineId,
8782
+ target: resolved.target,
8783
+ route: resolved.route,
8784
+ confidence: resolved.confidence,
8785
+ warnings: resolved.warnings
8786
+ };
8787
+ }
8788
+ function buildSshCommand(machineId, remoteCommand, options = {}) {
8789
+ const resolved = resolveSshTarget(machineId, options);
8790
+ return remoteCommand ? `ssh ${resolved.target} ${shellQuote2(remoteCommand)}` : `ssh ${resolved.target}`;
8791
+ }
8792
+
8793
+ // src/remote.ts
8794
+ function shellQuote3(value) {
8795
+ return `'${value.replace(/'/g, "'\\''")}'`;
8796
+ }
8797
+ function machineIsLocal(machineId, localMachineId) {
8798
+ return machineId === "local" || machineId === "localhost" || machineId === localMachineId || machineId === hostname4();
8799
+ }
8800
+ function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
8801
+ if (machineIsLocal(machineId, localMachineId)) {
8802
+ return { source: "local", shellCommand: command };
8803
+ }
8804
+ try {
8805
+ return {
8806
+ source: resolveSshTarget(machineId).route,
8807
+ shellCommand: buildSshCommand(machineId, command)
8808
+ };
8809
+ } catch (error) {
8810
+ const message = String(error.message ?? error);
8811
+ if (message.includes("Machine route not found") || message.includes("Machine not found in manifest")) {
8812
+ return { source: "ssh", shellCommand: `ssh ${shellQuote3(machineId)} ${shellQuote3(command)}` };
8813
+ }
8814
+ throw error;
8815
+ }
8816
+ }
8817
+ function runMachineCommand(machineId, command) {
8818
+ const resolved = resolveMachineCommand(machineId, command);
8819
+ const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
8820
+ encoding: "utf8",
8821
+ env: process.env
8822
+ });
8823
+ return {
8824
+ machineId,
8825
+ source: resolved.source,
8826
+ stdout: result.stdout || "",
8827
+ stderr: result.stderr || "",
8828
+ exitCode: result.status ?? 1
8829
+ };
8830
+ }
8831
+ function describeMachineCommandFailure(operation, result) {
8832
+ const detail = (result.stderr || result.stdout || "").trim();
8833
+ const suffix = detail ? `: ${detail}` : "";
8834
+ return `${operation} failed on ${result.machineId} via ${result.source} (exit ${result.exitCode})${suffix}`;
8835
+ }
8836
+ function requireMachineCommandSuccess(operation, result) {
8837
+ if (result.exitCode !== 0) {
8838
+ throw new Error(describeMachineCommandFailure(operation, result));
8839
+ }
8840
+ return result;
8841
+ }
8842
+
8843
+ // src/commands/setup.ts
8844
+ function quote(value) {
8845
+ return `'${value.replace(/'/g, `'\\''`)}'`;
8846
+ }
8847
+ function buildBaseSteps(machine) {
8848
+ const steps = [
8849
+ {
8850
+ id: "workspace",
8851
+ title: "Ensure workspace directory exists",
8852
+ command: `mkdir -p ${quote(machine.workspacePath)}`,
8853
+ manager: "shell"
8854
+ },
8855
+ {
8856
+ id: "bun",
8857
+ title: "Install Bun if missing",
8858
+ command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
8859
+ manager: "shell"
8860
+ }
8861
+ ];
8862
+ if (machine.platform === "linux") {
8863
+ steps.push({
8864
+ id: "apt-base",
8865
+ title: "Install core Linux tooling",
8866
+ command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
8867
+ manager: "apt",
8868
+ privileged: true
8869
+ });
8870
+ } else if (machine.platform === "macos") {
8871
+ steps.push({
8872
+ id: "brew-base",
8873
+ title: "Install Homebrew if missing",
8874
+ command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
8875
+ manager: "brew"
8876
+ });
8877
+ steps.push({
8878
+ id: "brew-core",
8879
+ title: "Install core macOS tooling",
8880
+ command: "brew install git coreutils",
8881
+ manager: "brew"
8882
+ });
8883
+ }
8884
+ return steps;
8885
+ }
8886
+ function buildPackageSteps(machine) {
8887
+ return (machine.packages || []).map((pkg, index) => {
8888
+ const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
8889
+ let command = pkg.name;
8890
+ if (manager === "bun") {
8891
+ command = `bun install -g ${quote(pkg.name)}`;
8892
+ } else if (manager === "brew") {
8893
+ command = `brew install ${quote(pkg.name)}`;
8894
+ } else if (manager === "apt") {
8895
+ command = `sudo apt-get install -y ${quote(pkg.name)}`;
8896
+ }
8897
+ return {
8898
+ id: `package-${index + 1}`,
8899
+ title: `Install package ${pkg.name}`,
8900
+ command,
8901
+ manager,
8902
+ privileged: manager === "apt"
8903
+ };
8904
+ });
8905
+ }
8906
+ function buildSetupPlan(machineId) {
8907
+ const manifest = readManifest();
8908
+ const currentMachineId = getLocalMachineId();
8909
+ const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
8910
+ const target = selected || {
8911
+ id: currentMachineId,
8912
+ platform: "linux",
8913
+ workspacePath: `${homedir3()}/workspace`
8914
+ };
8915
+ const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
8916
+ return {
8917
+ machineId: target.id,
8918
+ mode: "plan",
8919
+ steps,
8920
+ executed: 0
8921
+ };
8922
+ }
8923
+ function runSetup(machineId, options = {}, runner = runMachineCommand) {
8924
+ const plan = buildSetupPlan(machineId);
8925
+ if (!options.apply) {
8926
+ return plan;
8927
+ }
8928
+ if (!options.yes) {
8929
+ throw new Error("Setup execution requires --yes.");
8930
+ }
8931
+ let executed = 0;
8932
+ for (const step of plan.steps) {
8933
+ const result = runner(plan.machineId, step.command);
8934
+ if (result.exitCode !== 0) {
8935
+ recordSetupRun(plan.machineId, "failed", {
8936
+ executed,
8937
+ failedStep: step,
8938
+ stderr: result.stderr,
8939
+ stdout: result.stdout,
8940
+ exitCode: result.exitCode,
8941
+ source: result.source
8942
+ });
8943
+ throw new Error(describeMachineCommandFailure(`Setup step ${step.id}`, result));
8944
+ }
8945
+ executed += 1;
8946
+ }
8947
+ const summary = {
8948
+ machineId: plan.machineId,
8949
+ mode: "apply",
8950
+ steps: plan.steps,
8951
+ executed
8952
+ };
8953
+ recordSetupRun(plan.machineId, "completed", summary);
8954
+ return summary;
8955
+ }
8956
+
8957
+ // src/commands/backup.ts
8958
+ import { homedir as homedir4, hostname as hostname5 } from "os";
8959
+ import { join as join4 } from "path";
8960
+ var MACHINES_BACKUP_BUCKET_ENV = "HASNA_MACHINES_S3_BUCKET";
8961
+ var MACHINES_BACKUP_BUCKET_FALLBACK_ENV = "MACHINES_S3_BUCKET";
8962
+ var MACHINES_BACKUP_PREFIX_ENV = "HASNA_MACHINES_S3_PREFIX";
8963
+ var MACHINES_BACKUP_PREFIX_FALLBACK_ENV = "MACHINES_S3_PREFIX";
8964
+ var DEFAULT_BACKUP_PREFIX = "machines";
8965
+ function quote2(value) {
8966
+ return `'${value.replace(/'/g, `'\\''`)}'`;
8967
+ }
8968
+ function readEnv(name) {
8969
+ const value = process.env[name]?.trim();
8970
+ return value || undefined;
8971
+ }
8972
+ function readBackupBucketEnv() {
8973
+ const primary = readEnv(MACHINES_BACKUP_BUCKET_ENV);
8974
+ if (primary)
8975
+ return { bucket: primary, bucketSource: MACHINES_BACKUP_BUCKET_ENV };
8976
+ const fallback = readEnv(MACHINES_BACKUP_BUCKET_FALLBACK_ENV);
8977
+ if (fallback)
8978
+ return { bucket: fallback, bucketSource: MACHINES_BACKUP_BUCKET_FALLBACK_ENV };
8979
+ return null;
8980
+ }
8981
+ function readBackupPrefixEnv() {
8982
+ const primary = readEnv(MACHINES_BACKUP_PREFIX_ENV);
8983
+ if (primary)
8984
+ return { prefix: primary, prefixSource: MACHINES_BACKUP_PREFIX_ENV };
8985
+ const fallback = readEnv(MACHINES_BACKUP_PREFIX_FALLBACK_ENV);
8986
+ if (fallback)
8987
+ return { prefix: fallback, prefixSource: MACHINES_BACKUP_PREFIX_FALLBACK_ENV };
8988
+ return null;
8989
+ }
8990
+ function resolveBackupTarget(options = {}) {
8991
+ const explicitBucket = options.bucket?.trim();
8992
+ const envBucket = explicitBucket ? null : readBackupBucketEnv();
8993
+ const bucket = explicitBucket || envBucket?.bucket;
8994
+ if (!bucket) {
8995
+ throw new Error(`Missing S3 backup bucket. Pass --bucket or set ${MACHINES_BACKUP_BUCKET_ENV} or ${MACHINES_BACKUP_BUCKET_FALLBACK_ENV}.`);
8996
+ }
8997
+ const explicitPrefix = options.prefix?.trim();
8998
+ const envPrefix = explicitPrefix ? null : readBackupPrefixEnv();
8999
+ return {
9000
+ bucket,
9001
+ prefix: explicitPrefix || envPrefix?.prefix || DEFAULT_BACKUP_PREFIX,
9002
+ bucketSource: explicitBucket ? "argument" : envBucket.bucketSource,
9003
+ prefixSource: explicitPrefix ? "argument" : envPrefix?.prefixSource || "default"
9004
+ };
9005
+ }
9006
+ function defaultBackupSources() {
9007
+ const home = homedir4();
9008
+ return [
9009
+ join4(home, ".hasna"),
9010
+ join4(home, ".ssh"),
9011
+ join4(home, ".secrets")
9012
+ ];
9013
+ }
9014
+ function buildBackupPlan(bucket, prefix) {
9015
+ const target = resolveBackupTarget({ bucket, prefix });
9016
+ const archivePath = join4(homedir4(), ".hasna", "machines", "backup.tgz");
9017
+ const sources = defaultBackupSources();
9018
+ const steps = [
9019
+ {
9020
+ id: "backup-archive",
9021
+ title: "Create compressed machine backup archive",
9022
+ command: `tar -czf ${quote2(archivePath)} ${sources.map((source) => quote2(source)).join(" ")}`,
9023
+ manager: "shell"
9024
+ },
9025
+ {
9026
+ id: "backup-upload",
9027
+ title: "Upload archive to S3",
9028
+ command: `aws s3 cp ${quote2(archivePath)} ${quote2(`s3://${target.bucket}/${target.prefix}/${hostname5()}-backup.tgz`)}`,
9029
+ manager: "custom"
9030
+ }
9031
+ ];
9032
+ return {
9033
+ machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
9034
+ mode: "plan",
9035
+ steps,
9036
+ executed: 0
9037
+ };
9038
+ }
9039
+ function runBackup(bucket, prefix, options = {}) {
9040
+ const plan = buildBackupPlan(bucket, prefix);
9041
+ if (!options.apply)
9042
+ return plan;
9043
+ if (!options.yes) {
9044
+ throw new Error("Backup execution requires --yes.");
9045
+ }
9046
+ let executed = 0;
9047
+ for (const step of plan.steps) {
9048
+ const result = Bun.spawnSync(["bash", "-lc", step.command], {
9049
+ stdout: "pipe",
9050
+ stderr: "pipe",
9051
+ env: process.env
9052
+ });
9053
+ if (result.exitCode !== 0) {
9054
+ throw new Error(`Backup step failed (${step.id}): ${result.stderr.toString().trim()}`);
9055
+ }
9056
+ executed += 1;
9057
+ }
9147
9058
  return {
9148
- ...resolution,
9149
- diagnostics: diagnostics.diagnostics,
9150
- repair_hints: diagnostics.repairHints
9059
+ machineId: plan.machineId,
9060
+ mode: "apply",
9061
+ steps: plan.steps,
9062
+ executed
9151
9063
  };
9152
9064
  }
9153
9065
 
9154
- // src/commands/ssh.ts
9155
- function shellQuote2(value) {
9156
- return `'${value.replace(/'/g, "'\\''")}'`;
9066
+ // src/commands/cert.ts
9067
+ import { homedir as homedir5, platform as platform3 } from "os";
9068
+ import { join as join5 } from "path";
9069
+ function quote3(value) {
9070
+ return `'${value.replace(/'/g, `'\\''`)}'`;
9157
9071
  }
9158
- function resolveSshTarget(machineId, options = {}) {
9159
- const resolved = resolveMachineRoute(machineId, options);
9160
- if (!resolved.ok || !resolved.target) {
9161
- throw new Error(`Machine route not found: ${machineId}`);
9072
+ function certDir() {
9073
+ return join5(homedir5(), ".hasna", "machines", "certs");
9074
+ }
9075
+ function buildCertPlan(domains) {
9076
+ if (domains.length === 0) {
9077
+ throw new Error("At least one domain is required.");
9162
9078
  }
9163
- if (resolved.route !== "local" && resolved.route !== "lan" && resolved.route !== "tailscale" && resolved.route !== "ssh") {
9164
- throw new Error(`Machine route is not SSH-capable: ${machineId}`);
9079
+ const primary = domains[0];
9080
+ const certPath = join5(certDir(), `${primary}.pem`);
9081
+ const keyPath = join5(certDir(), `${primary}-key.pem`);
9082
+ const steps = [];
9083
+ if (platform3() === "darwin") {
9084
+ steps.push({
9085
+ id: "mkcert-install-macos",
9086
+ title: "Install mkcert on macOS",
9087
+ command: "brew install mkcert nss",
9088
+ manager: "brew"
9089
+ });
9090
+ } else {
9091
+ steps.push({
9092
+ id: "mkcert-install-linux",
9093
+ title: "Install mkcert on Linux",
9094
+ command: "sudo apt-get update && sudo apt-get install -y mkcert libnss3-tools",
9095
+ manager: "apt",
9096
+ privileged: true
9097
+ });
9165
9098
  }
9099
+ steps.push({
9100
+ id: "mkcert-local-ca",
9101
+ title: "Install local mkcert CA",
9102
+ command: "mkcert -install",
9103
+ manager: "custom"
9104
+ }, {
9105
+ id: "mkcert-issue",
9106
+ title: `Issue certificate for ${domains.join(", ")}`,
9107
+ command: `mkdir -p ${quote3(certDir())} && mkcert -cert-file ${quote3(certPath)} -key-file ${quote3(keyPath)} ${domains.map((domain) => quote3(domain)).join(" ")}`,
9108
+ manager: "custom"
9109
+ });
9166
9110
  return {
9167
- machineId: resolved.machine_id ?? machineId,
9168
- target: resolved.target,
9169
- route: resolved.route,
9170
- confidence: resolved.confidence,
9171
- warnings: resolved.warnings
9111
+ machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
9112
+ mode: "plan",
9113
+ steps,
9114
+ executed: 0
9172
9115
  };
9173
9116
  }
9174
- function buildSshCommand(machineId, remoteCommand, options = {}) {
9175
- const resolved = resolveSshTarget(machineId, options);
9176
- return remoteCommand ? `ssh ${resolved.target} ${shellQuote2(remoteCommand)}` : `ssh ${resolved.target}`;
9117
+ function runCertPlan(domains, options = {}) {
9118
+ const plan = buildCertPlan(domains);
9119
+ if (!options.apply)
9120
+ return plan;
9121
+ if (!options.yes) {
9122
+ throw new Error("Certificate generation requires --yes.");
9123
+ }
9124
+ let executed = 0;
9125
+ for (const step of plan.steps) {
9126
+ const result = Bun.spawnSync(["bash", "-lc", step.command], {
9127
+ stdout: "pipe",
9128
+ stderr: "pipe",
9129
+ env: process.env
9130
+ });
9131
+ if (result.exitCode !== 0) {
9132
+ throw new Error(`Certificate step failed (${step.id}): ${result.stderr.toString().trim()}`);
9133
+ }
9134
+ executed += 1;
9135
+ }
9136
+ return {
9137
+ machineId: plan.machineId,
9138
+ mode: "apply",
9139
+ steps: plan.steps,
9140
+ executed
9141
+ };
9177
9142
  }
9178
9143
 
9179
- // src/remote.ts
9180
- function shellQuote3(value) {
9181
- return `'${value.replace(/'/g, "'\\''")}'`;
9144
+ // src/commands/dns.ts
9145
+ init_paths();
9146
+ import { existsSync as existsSync6, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
9147
+ import { join as join6 } from "path";
9148
+ function getDnsPath() {
9149
+ return join6(getDataDir(), "dns.json");
9182
9150
  }
9183
- function machineIsLocal(machineId, localMachineId) {
9184
- return machineId === "local" || machineId === "localhost" || machineId === localMachineId || machineId === hostname5();
9151
+ function readMappings() {
9152
+ const path = getDnsPath();
9153
+ if (!existsSync6(path))
9154
+ return [];
9155
+ return JSON.parse(readFileSync3(path, "utf8"));
9185
9156
  }
9186
- function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
9187
- if (machineIsLocal(machineId, localMachineId)) {
9188
- return { source: "local", shellCommand: command };
9189
- }
9190
- try {
9191
- return {
9192
- source: resolveSshTarget(machineId).route,
9193
- shellCommand: buildSshCommand(machineId, command)
9194
- };
9195
- } catch (error) {
9196
- const message = String(error.message ?? error);
9197
- if (message.includes("Machine route not found") || message.includes("Machine not found in manifest")) {
9198
- return { source: "ssh", shellCommand: `ssh ${shellQuote3(machineId)} ${shellQuote3(command)}` };
9199
- }
9200
- throw error;
9157
+ function writeMappings(mappings) {
9158
+ const path = getDnsPath();
9159
+ ensureParentDir(path);
9160
+ writeFileSync2(path, `${JSON.stringify(mappings, null, 2)}
9161
+ `, "utf8");
9162
+ return path;
9163
+ }
9164
+ function addDomainMapping(domain, port, targetHost = "127.0.0.1") {
9165
+ const mappings = readMappings().filter((entry) => entry.domain !== domain);
9166
+ mappings.push({ domain, port, targetHost });
9167
+ writeMappings(mappings);
9168
+ return mappings.sort((left, right) => left.domain.localeCompare(right.domain));
9169
+ }
9170
+ function listDomainMappings() {
9171
+ return readMappings().sort((left, right) => left.domain.localeCompare(right.domain));
9172
+ }
9173
+ function renderDomainMapping(domain) {
9174
+ const entry = readMappings().find((mapping) => mapping.domain === domain);
9175
+ if (!entry) {
9176
+ throw new Error(`Domain mapping not found: ${domain}`);
9201
9177
  }
9178
+ return {
9179
+ hostsEntry: `${entry.targetHost} ${entry.domain}`,
9180
+ caddySnippet: `${entry.domain} {
9181
+ reverse_proxy 127.0.0.1:${entry.port}
9182
+ tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
9183
+ }`,
9184
+ certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
9185
+ keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
9186
+ };
9202
9187
  }
9203
- function runMachineCommand(machineId, command) {
9204
- const resolved = resolveMachineCommand(machineId, command);
9205
- const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
9206
- encoding: "utf8",
9207
- env: process.env
9208
- });
9188
+
9189
+ // src/commands/diff.ts
9190
+ function packageNames(machine) {
9191
+ return (machine.packages || []).map((pkg) => pkg.name).sort();
9192
+ }
9193
+ function fileTargets(machine) {
9194
+ return (machine.files || []).map((file) => `${file.source}->${file.target}`).sort();
9195
+ }
9196
+ function diffMachines(leftMachineId, rightMachineId) {
9197
+ const left = getManifestMachine(leftMachineId);
9198
+ if (!left) {
9199
+ throw new Error(`Machine not found in manifest: ${leftMachineId}`);
9200
+ }
9201
+ const right = rightMachineId ? getManifestMachine(rightMachineId) : detectCurrentMachineManifest();
9202
+ if (!right) {
9203
+ throw new Error(`Machine not found in manifest: ${rightMachineId}`);
9204
+ }
9205
+ const changedFields = [
9206
+ left.platform !== right.platform ? "platform" : null,
9207
+ left.connection !== right.connection ? "connection" : null,
9208
+ left.workspacePath !== right.workspacePath ? "workspacePath" : null,
9209
+ left.bunPath !== right.bunPath ? "bunPath" : null
9210
+ ].filter(Boolean);
9211
+ const leftPackages = new Set(packageNames(left));
9212
+ const rightPackages = new Set(packageNames(right));
9213
+ const leftFiles = new Set(fileTargets(left));
9214
+ const rightFiles = new Set(fileTargets(right));
9209
9215
  return {
9210
- machineId,
9211
- source: resolved.source,
9212
- stdout: result.stdout || "",
9213
- stderr: result.stderr || "",
9214
- exitCode: result.status ?? 1
9216
+ leftMachineId: left.id,
9217
+ rightMachineId: right.id,
9218
+ changedFields,
9219
+ missingPackages: {
9220
+ leftOnly: [...leftPackages].filter((pkg) => !rightPackages.has(pkg)),
9221
+ rightOnly: [...rightPackages].filter((pkg) => !leftPackages.has(pkg))
9222
+ },
9223
+ missingFiles: {
9224
+ leftOnly: [...leftFiles].filter((file) => !rightFiles.has(file)),
9225
+ rightOnly: [...rightFiles].filter((file) => !leftFiles.has(file))
9226
+ }
9215
9227
  };
9216
9228
  }
9217
9229
 
@@ -9307,27 +9319,28 @@ function buildAppsPlan(machineId) {
9307
9319
  executed: 0
9308
9320
  };
9309
9321
  }
9310
- function getAppsStatus(machineId) {
9322
+ function getAppsStatus(machineId, runner = runMachineCommand) {
9311
9323
  const machine = resolveMachine(machineId);
9324
+ const readiness = requireMachineCommandSuccess("Apps status readiness check", runner(machine.id, "true"));
9312
9325
  const apps = (machine.apps || []).map((app) => {
9313
- const probe = runMachineCommand(machine.id, buildAppProbeCommand(machine, app));
9326
+ const probe = requireMachineCommandSuccess(`App probe ${app.name}`, runner(machine.id, buildAppProbeCommand(machine, app)));
9314
9327
  return parseProbeOutput(app, machine, probe.stdout);
9315
9328
  });
9316
9329
  return {
9317
9330
  machineId: machine.id,
9318
- source: apps.length > 0 ? runMachineCommand(machine.id, "true").source : machine.id === detectCurrentMachineManifest().id ? "local" : runMachineCommand(machine.id, "true").source,
9331
+ source: readiness.source,
9319
9332
  apps
9320
9333
  };
9321
9334
  }
9322
- function diffApps(machineId) {
9323
- const status = getAppsStatus(machineId);
9335
+ function diffApps(machineId, runner = runMachineCommand) {
9336
+ const status = getAppsStatus(machineId, runner);
9324
9337
  return {
9325
9338
  ...status,
9326
9339
  missing: status.apps.filter((app) => !app.installed).map((app) => app.name),
9327
9340
  installed: status.apps.filter((app) => app.installed).map((app) => app.name)
9328
9341
  };
9329
9342
  }
9330
- function runAppsInstall(machineId, options = {}) {
9343
+ function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
9331
9344
  const plan = buildAppsPlan(machineId);
9332
9345
  if (!options.apply)
9333
9346
  return plan;
@@ -9336,14 +9349,7 @@ function runAppsInstall(machineId, options = {}) {
9336
9349
  }
9337
9350
  let executed = 0;
9338
9351
  for (const step of plan.steps) {
9339
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
9340
- stdout: "pipe",
9341
- stderr: "pipe",
9342
- env: process.env
9343
- });
9344
- if (result.exitCode !== 0) {
9345
- throw new Error(`App install failed (${step.id}): ${result.stderr.toString().trim()}`);
9346
- }
9352
+ requireMachineCommandSuccess(`App install ${step.id}`, runner(plan.machineId, step.command));
9347
9353
  executed += 1;
9348
9354
  }
9349
9355
  return {
@@ -9414,25 +9420,28 @@ function buildClaudeInstallPlan(machineId, tools) {
9414
9420
  executed: 0
9415
9421
  };
9416
9422
  }
9417
- function getClaudeCliStatus(machineId, tools) {
9423
+ function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
9418
9424
  const machine = resolveMachine2(machineId);
9419
9425
  const normalizedTools = normalizeTools(tools);
9420
- const route = runMachineCommand(machine.id, "true").source;
9426
+ const route = requireMachineCommandSuccess("AI CLI status readiness check", runner(machine.id, "true")).source;
9421
9427
  return {
9422
9428
  machineId: machine.id,
9423
9429
  source: route,
9424
- tools: normalizedTools.map((tool) => parseProbe(tool, runMachineCommand(machine.id, buildProbeCommand(tool)).stdout))
9430
+ tools: normalizedTools.map((tool) => {
9431
+ const result = requireMachineCommandSuccess(`AI CLI probe ${tool}`, runner(machine.id, buildProbeCommand(tool)));
9432
+ return parseProbe(tool, result.stdout);
9433
+ })
9425
9434
  };
9426
9435
  }
9427
- function diffClaudeCli(machineId, tools) {
9428
- const status = getClaudeCliStatus(machineId, tools);
9436
+ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
9437
+ const status = getClaudeCliStatus(machineId, tools, runner);
9429
9438
  return {
9430
9439
  ...status,
9431
9440
  missing: status.tools.filter((tool) => !tool.installed).map((tool) => tool.tool),
9432
9441
  installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
9433
9442
  };
9434
9443
  }
9435
- function runClaudeInstall(machineId, tools, options = {}) {
9444
+ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCommand) {
9436
9445
  const plan = buildClaudeInstallPlan(machineId, tools);
9437
9446
  if (!options.apply)
9438
9447
  return plan;
@@ -9441,14 +9450,7 @@ function runClaudeInstall(machineId, tools, options = {}) {
9441
9450
  }
9442
9451
  let executed = 0;
9443
9452
  for (const step of plan.steps) {
9444
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
9445
- stdout: "pipe",
9446
- stderr: "pipe",
9447
- env: process.env
9448
- });
9449
- if (result.exitCode !== 0) {
9450
- throw new Error(`AI CLI install failed (${step.id}): ${result.stderr.toString().trim()}`);
9451
- }
9453
+ requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
9452
9454
  executed += 1;
9453
9455
  }
9454
9456
  return {
@@ -9500,7 +9502,7 @@ function buildTailscaleInstallPlan(machineId) {
9500
9502
  executed: 0
9501
9503
  };
9502
9504
  }
9503
- function runTailscaleInstall(machineId, options = {}) {
9505
+ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand) {
9504
9506
  const plan = buildTailscaleInstallPlan(machineId);
9505
9507
  if (!options.apply)
9506
9508
  return plan;
@@ -9509,14 +9511,7 @@ function runTailscaleInstall(machineId, options = {}) {
9509
9511
  }
9510
9512
  let executed = 0;
9511
9513
  for (const step of plan.steps) {
9512
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
9513
- stdout: "pipe",
9514
- stderr: "pipe",
9515
- env: process.env
9516
- });
9517
- if (result.exitCode !== 0) {
9518
- throw new Error(`Tailscale install failed (${step.id}): ${result.stderr.toString().trim()}`);
9519
- }
9514
+ requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
9520
9515
  executed += 1;
9521
9516
  }
9522
9517
  return {
@@ -10557,16 +10552,17 @@ function quote4(value) {
10557
10552
  return `'${value.replace(/'/g, `'\\''`)}'`;
10558
10553
  }
10559
10554
  function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
10555
+ const quotedPackageName = quote4(packageName);
10560
10556
  if (manager === "bun") {
10561
- return `bun pm ls -g --all | grep -F ${quote4(packageName)}`;
10557
+ 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`;
10562
10558
  }
10563
10559
  if (manager === "brew") {
10564
- return `brew list --versions ${quote4(packageName)}`;
10560
+ return `if brew list --versions ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
10565
10561
  }
10566
10562
  if (manager === "apt") {
10567
- return `dpkg -s ${quote4(packageName)} >/dev/null 2>&1`;
10563
+ return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
10568
10564
  }
10569
- return `command -v ${quote4(packageName)} >/dev/null 2>&1`;
10565
+ return `if command -v ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
10570
10566
  }
10571
10567
  function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
10572
10568
  if (manager === "bun") {
@@ -10580,15 +10576,15 @@ function packageInstallCommand(machine, packageName, manager = machine.platform
10580
10576
  }
10581
10577
  return packageName;
10582
10578
  }
10583
- function detectPackageActions(machine) {
10579
+ function detectPackageActions(machine, runner) {
10584
10580
  return (machine.packages || []).map((pkg, index) => {
10585
10581
  const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
10586
- const check = Bun.spawnSync(["bash", "-lc", packageCheckCommand(machine, pkg.name, manager)], {
10587
- stdout: "ignore",
10588
- stderr: "ignore",
10589
- env: process.env
10590
- });
10591
- const installed = check.exitCode === 0;
10582
+ const check = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
10583
+ if (check.exitCode !== 0) {
10584
+ throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check));
10585
+ }
10586
+ const installed = check.stdout.split(`
10587
+ `).some((line) => line.trim() === "installed=1");
10592
10588
  return {
10593
10589
  id: `package-${index + 1}`,
10594
10590
  title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
@@ -10599,6 +10595,9 @@ function detectPackageActions(machine) {
10599
10595
  });
10600
10596
  }
10601
10597
  function detectFileActions(machine) {
10598
+ if ((machine.files || []).length > 0 && resolveMachineCommand(machine.id, "true").source !== "local") {
10599
+ throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
10600
+ }
10602
10601
  return (machine.files || []).map((file, index) => {
10603
10602
  const sourceExists = existsSync9(file.source);
10604
10603
  const targetExists = existsSync9(file.target);
@@ -10622,7 +10621,7 @@ function detectFileActions(machine) {
10622
10621
  };
10623
10622
  });
10624
10623
  }
10625
- function buildSyncPlan(machineId) {
10624
+ function buildSyncPlan(machineId, runner = runMachineCommand) {
10626
10625
  const manifest = readManifest();
10627
10626
  const currentMachineId = getLocalMachineId();
10628
10627
  const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
@@ -10632,7 +10631,7 @@ function buildSyncPlan(machineId) {
10632
10631
  workspacePath: `${homedir7()}/workspace`
10633
10632
  };
10634
10633
  const actions = [
10635
- ...detectPackageActions(target),
10634
+ ...detectPackageActions(target, runner),
10636
10635
  ...detectFileActions(target)
10637
10636
  ];
10638
10637
  return {
@@ -10662,8 +10661,8 @@ function applyFileAction(command) {
10662
10661
  symlinkSync(sourcePath, targetPath);
10663
10662
  }
10664
10663
  }
10665
- function runSync(machineId, options = {}) {
10666
- const plan = buildSyncPlan(machineId);
10664
+ function runSync(machineId, options = {}, runner = runMachineCommand) {
10665
+ const plan = buildSyncPlan(machineId, runner);
10667
10666
  if (!options.apply) {
10668
10667
  return plan;
10669
10668
  }
@@ -10677,18 +10676,17 @@ function runSync(machineId, options = {}) {
10677
10676
  if (action.kind === "file") {
10678
10677
  applyFileAction(action.command);
10679
10678
  } else {
10680
- const result = Bun.spawnSync(["bash", "-lc", action.command], {
10681
- stdout: "pipe",
10682
- stderr: "pipe",
10683
- env: process.env
10684
- });
10679
+ const result = runner(plan.machineId, action.command);
10685
10680
  if (result.exitCode !== 0) {
10686
10681
  recordSyncRun(plan.machineId, "failed", {
10687
10682
  executed,
10688
10683
  failedAction: action,
10689
- stderr: result.stderr.toString()
10684
+ stderr: result.stderr,
10685
+ stdout: result.stdout,
10686
+ exitCode: result.exitCode,
10687
+ source: result.source
10690
10688
  });
10691
- throw new Error(`Sync action failed (${action.id}): ${result.stderr.toString().trim()}`);
10689
+ throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
10692
10690
  }
10693
10691
  }
10694
10692
  executed += 1;