@hasna/machines 0.0.30 → 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
@@ -3164,15 +3164,9 @@ function print(value, json, text) {
3164
3164
  else
3165
3165
  console.log(text);
3166
3166
  }
3167
- function hasJsonOption(options) {
3168
- return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
3169
- }
3170
- function wantsJson(actionOptions, command) {
3171
- return hasJsonOption(actionOptions) || hasJsonOption(command);
3172
- }
3173
3167
  function registerWebhookCommands(program2, options) {
3174
3168
  const webhooks = program2.command(options.webhooksCommandName ?? "webhooks").description("Manage Hasna event webhook subscriptions");
3175
- webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
3169
+ webhooks.command("add").description("Add or replace a webhook or command subscription").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Subscription/channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions) => {
3176
3170
  const timestamp = new Date().toISOString();
3177
3171
  const channel = {
3178
3172
  id: actionOptions.id,
@@ -3193,11 +3187,11 @@ function registerWebhookCommands(program2, options) {
3193
3187
  throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
3194
3188
  }
3195
3189
  const saved = await createClient(options).addChannel(channel);
3196
- print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
3190
+ print(sanitizeChannelForOutput(saved), Boolean(actionOptions.json), `Added ${saved.transport} channel ${saved.id}`);
3197
3191
  });
3198
- webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
3192
+ webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
3199
3193
  const channels = await createClient(options).listChannels();
3200
- if (wantsJson(actionOptions, command)) {
3194
+ if (actionOptions.json) {
3201
3195
  console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
3202
3196
  return;
3203
3197
  }
@@ -3209,11 +3203,11 @@ function registerWebhookCommands(program2, options) {
3209
3203
  console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
3210
3204
  }
3211
3205
  });
3212
- webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
3206
+ webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions) => {
3213
3207
  const removed = await createClient(options).removeChannel(id);
3214
- print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
3208
+ print({ removed }, Boolean(actionOptions.json), removed ? `Removed ${id}` : `Channel not found: ${id}`);
3215
3209
  });
3216
- webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
3210
+ webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions) => {
3217
3211
  const result = await createClient(options).testChannel(id, {
3218
3212
  source: options.source,
3219
3213
  type: actionOptions.type,
@@ -3221,13 +3215,13 @@ function registerWebhookCommands(program2, options) {
3221
3215
  message: actionOptions.message,
3222
3216
  data: parseJsonObject(actionOptions.data, { test: true })
3223
3217
  });
3224
- print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
3218
+ print(result, Boolean(actionOptions.json), `${result.status}: ${result.channelId}`);
3225
3219
  });
3226
3220
  return webhooks;
3227
3221
  }
3228
3222
  function registerEventCommands(program2, options) {
3229
3223
  const events = program2.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
3230
- events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions, command) => {
3224
+ events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions) => {
3231
3225
  const result = await createClient(options).emit({
3232
3226
  source: actionOptions.source ?? options.source,
3233
3227
  type,
@@ -3238,9 +3232,9 @@ function registerEventCommands(program2, options) {
3238
3232
  data: parseJsonObject(actionOptions.data, {}),
3239
3233
  metadata: parseJsonObject(actionOptions.metadata, {})
3240
3234
  }, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
3241
- print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
3235
+ print(result, Boolean(actionOptions.json), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
3242
3236
  });
3243
- events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
3237
+ events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
3244
3238
  let rows = await createClient(options).listEvents();
3245
3239
  if (actionOptions.source)
3246
3240
  rows = rows.filter((event) => event.source === actionOptions.source);
@@ -3248,7 +3242,7 @@ function registerEventCommands(program2, options) {
3248
3242
  rows = rows.filter((event) => event.type === actionOptions.type);
3249
3243
  if (actionOptions.limit)
3250
3244
  rows = rows.slice(-actionOptions.limit);
3251
- if (wantsJson(actionOptions, command)) {
3245
+ if (actionOptions.json) {
3252
3246
  console.log(JSON.stringify(rows, null, 2));
3253
3247
  return;
3254
3248
  }
@@ -3259,14 +3253,14 @@ function registerEventCommands(program2, options) {
3259
3253
  for (const event of rows)
3260
3254
  console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
3261
3255
  });
3262
- events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
3256
+ events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions) => {
3263
3257
  const result = await createClient(options).replay({
3264
3258
  eventId: actionOptions.id,
3265
3259
  source: actionOptions.source,
3266
3260
  type: actionOptions.type,
3267
3261
  dryRun: actionOptions.dryRun
3268
3262
  });
3269
- print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
3263
+ print(result, Boolean(actionOptions.json), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
3270
3264
  });
3271
3265
  return events;
3272
3266
  }
@@ -7918,996 +7912,610 @@ function manifestValidate() {
7918
7912
  // src/commands/setup.ts
7919
7913
  import { homedir as homedir3 } from "os";
7920
7914
  init_db();
7921
- function quote(value) {
7922
- 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 };
7923
7944
  }
7924
- function buildBaseSteps(machine) {
7925
- const steps = [
7926
- {
7927
- id: "workspace",
7928
- title: "Ensure workspace directory exists",
7929
- command: `mkdir -p ${quote(machine.workspacePath)}`,
7930
- manager: "shell"
7931
- },
7932
- {
7933
- id: "bun",
7934
- title: "Install Bun if missing",
7935
- command: "command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash",
7936
- manager: "shell"
7937
- }
7938
- ];
7939
- if (machine.platform === "linux") {
7940
- steps.push({
7941
- id: "apt-base",
7942
- title: "Install core Linux tooling",
7943
- command: "sudo apt-get update && sudo apt-get install -y git curl unzip build-essential",
7944
- manager: "apt",
7945
- privileged: true
7946
- });
7947
- } else if (machine.platform === "macos") {
7948
- steps.push({
7949
- id: "brew-base",
7950
- title: "Install Homebrew if missing",
7951
- command: 'command -v brew >/dev/null 2>&1 || /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
7952
- manager: "brew"
7953
- });
7954
- steps.push({
7955
- id: "brew-core",
7956
- title: "Install core macOS tooling",
7957
- command: "brew install git coreutils",
7958
- manager: "brew"
7959
- });
7960
- }
7961
- 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;
7962
7954
  }
7963
- function buildPackageSteps(machine) {
7964
- return (machine.packages || []).map((pkg, index) => {
7965
- const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
7966
- let command = pkg.name;
7967
- if (manager === "bun") {
7968
- command = `bun install -g ${quote(pkg.name)}`;
7969
- } else if (manager === "brew") {
7970
- command = `brew install ${quote(pkg.name)}`;
7971
- } else if (manager === "apt") {
7972
- command = `sudo apt-get install -y ${quote(pkg.name)}`;
7973
- }
7974
- return {
7975
- id: `package-${index + 1}`,
7976
- title: `Install package ${pkg.name}`,
7977
- command,
7978
- manager,
7979
- privileged: manager === "apt"
7980
- };
7955
+ function defaultRunner(command) {
7956
+ const result = spawnSync("bash", ["-c", command], {
7957
+ encoding: "utf8",
7958
+ env: process.env
7981
7959
  });
7982
- }
7983
- function buildSetupPlan(machineId) {
7984
- const manifest = readManifest();
7985
- const currentMachineId = getLocalMachineId();
7986
- const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
7987
- const target = selected || {
7988
- id: currentMachineId,
7989
- platform: "linux",
7990
- workspacePath: `${homedir3()}/workspace`
7991
- };
7992
- const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
7993
7960
  return {
7994
- machineId: target.id,
7995
- mode: "plan",
7996
- steps,
7997
- executed: 0
7961
+ stdout: result.stdout || "",
7962
+ stderr: result.stderr || "",
7963
+ exitCode: result.status ?? 1
7998
7964
  };
7999
7965
  }
8000
- function runSetup(machineId, options = {}) {
8001
- const plan = buildSetupPlan(machineId);
8002
- if (!options.apply) {
8003
- 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;
8004
7977
  }
8005
- if (!options.yes) {
8006
- 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;
8007
7984
  }
8008
- let executed = 0;
8009
- for (const step of plan.steps) {
8010
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
8011
- stdout: "pipe",
8012
- stderr: "pipe",
8013
- env: process.env
8014
- });
8015
- if (result.exitCode !== 0) {
8016
- recordSetupRun(plan.machineId, "failed", {
8017
- executed,
8018
- failedStep: step,
8019
- stderr: result.stderr.toString()
8020
- });
8021
- throw new Error(`Setup step failed (${step.id}): ${result.stderr.toString().trim()}`);
8022
- }
8023
- 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;
8024
7989
  }
8025
- const summary = {
8026
- machineId: plan.machineId,
8027
- mode: "apply",
8028
- steps: plan.steps,
8029
- 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);
8030
8001
  };
8031
- recordSetupRun(plan.machineId, "completed", summary);
8032
- return summary;
8002
+ addPeer(status.Self);
8003
+ for (const peer of Object.values(status.Peer ?? {}))
8004
+ addPeer(peer);
8005
+ return peers;
8033
8006
  }
8034
-
8035
- // src/commands/backup.ts
8036
- import { homedir as homedir4, hostname as hostname3 } from "os";
8037
- import { join as join4 } from "path";
8038
- var MACHINES_BACKUP_BUCKET_ENV = "HASNA_MACHINES_S3_BUCKET";
8039
- var MACHINES_BACKUP_BUCKET_FALLBACK_ENV = "MACHINES_S3_BUCKET";
8040
- var MACHINES_BACKUP_PREFIX_ENV = "HASNA_MACHINES_S3_PREFIX";
8041
- var MACHINES_BACKUP_PREFIX_FALLBACK_ENV = "MACHINES_S3_PREFIX";
8042
- var DEFAULT_BACKUP_PREFIX = "machines";
8043
- function quote2(value) {
8044
- 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));
8045
8015
  }
8046
- function readEnv(name) {
8047
- const value = process.env[name]?.trim();
8048
- 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;
8049
8025
  }
8050
- function readBackupBucketEnv() {
8051
- const primary = readEnv(MACHINES_BACKUP_BUCKET_ENV);
8052
- if (primary)
8053
- return { bucket: primary, bucketSource: MACHINES_BACKUP_BUCKET_ENV };
8054
- const fallback = readEnv(MACHINES_BACKUP_BUCKET_FALLBACK_ENV);
8055
- if (fallback)
8056
- return { bucket: fallback, bucketSource: MACHINES_BACKUP_BUCKET_FALLBACK_ENV };
8057
- 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));
8058
8029
  }
8059
- function readBackupPrefixEnv() {
8060
- const primary = readEnv(MACHINES_BACKUP_PREFIX_ENV);
8061
- if (primary)
8062
- return { prefix: primary, prefixSource: MACHINES_BACKUP_PREFIX_ENV };
8063
- const fallback = readEnv(MACHINES_BACKUP_PREFIX_FALLBACK_ENV);
8064
- if (fallback)
8065
- return { prefix: fallback, prefixSource: MACHINES_BACKUP_PREFIX_FALLBACK_ENV };
8066
- return null;
8030
+ function manifestHostReachable(target) {
8031
+ const overrides = envReachableHosts();
8032
+ if (overrides.size === 0)
8033
+ return null;
8034
+ return overrides.has(target);
8067
8035
  }
8068
- function resolveBackupTarget(options = {}) {
8069
- const explicitBucket = options.bucket?.trim();
8070
- const envBucket = explicitBucket ? null : readBackupBucketEnv();
8071
- const bucket = explicitBucket || envBucket?.bucket;
8072
- if (!bucket) {
8073
- 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 });
8074
8040
  }
8075
- const explicitPrefix = options.prefix?.trim();
8076
- const envPrefix = explicitPrefix ? null : readBackupPrefixEnv();
8077
- return {
8078
- bucket,
8079
- prefix: explicitPrefix || envPrefix?.prefix || DEFAULT_BACKUP_PREFIX,
8080
- bucketSource: explicitBucket ? "argument" : envBucket.bucketSource,
8081
- prefixSource: explicitPrefix ? "argument" : envPrefix?.prefixSource || "default"
8082
- };
8083
- }
8084
- function defaultBackupSources() {
8085
- const home = homedir4();
8086
- return [
8087
- join4(home, ".hasna"),
8088
- join4(home, ".ssh"),
8089
- join4(home, ".secrets")
8090
- ];
8091
- }
8092
- function buildBackupPlan(bucket, prefix) {
8093
- const target = resolveBackupTarget({ bucket, prefix });
8094
- const archivePath = join4(homedir4(), ".hasna", "machines", "backup.tgz");
8095
- const sources = defaultBackupSources();
8096
- const steps = [
8097
- {
8098
- id: "backup-archive",
8099
- title: "Create compressed machine backup archive",
8100
- command: `tar -czf ${quote2(archivePath)} ${sources.map((source) => quote2(source)).join(" ")}`,
8101
- manager: "shell"
8102
- },
8103
- {
8104
- id: "backup-upload",
8105
- title: "Upload archive to S3",
8106
- command: `aws s3 cp ${quote2(archivePath)} ${quote2(`s3://${target.bucket}/${target.prefix}/${hostname3()}-backup.tgz`)}`,
8107
- manager: "custom"
8108
- }
8109
- ];
8110
- return {
8111
- machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
8112
- mode: "plan",
8113
- steps,
8114
- executed: 0
8115
- };
8116
- }
8117
- function runBackup(bucket, prefix, options = {}) {
8118
- const plan = buildBackupPlan(bucket, prefix);
8119
- if (!options.apply)
8120
- return plan;
8121
- if (!options.yes) {
8122
- 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) });
8123
8043
  }
8124
- let executed = 0;
8125
- for (const step of plan.steps) {
8126
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
8127
- stdout: "pipe",
8128
- stderr: "pipe",
8129
- env: process.env
8130
- });
8131
- if (result.exitCode !== 0) {
8132
- throw new Error(`Backup step failed (${step.id}): ${result.stderr.toString().trim()}`);
8133
- }
8134
- executed += 1;
8044
+ if (input.manifest?.hostname) {
8045
+ hints.push({ kind: "lan", target: input.manifest.hostname, reachable: manifestHostReachable(input.manifest.hostname) });
8135
8046
  }
8136
- return {
8137
- machineId: plan.machineId,
8138
- mode: "apply",
8139
- steps: plan.steps,
8140
- executed
8141
- };
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;
8142
8052
  }
8143
-
8144
- // src/commands/cert.ts
8145
- import { homedir as homedir5, platform as platform2 } from "os";
8146
- import { join as join5 } from "path";
8147
- function quote3(value) {
8148
- 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;
8149
8071
  }
8150
- function certDir() {
8151
- return join5(homedir5(), ".hasna", "machines", "certs");
8072
+ function selectRouteHint(hints) {
8073
+ return [...hints].sort((left, right) => routeRank(left) - routeRank(right))[0] ?? null;
8152
8074
  }
8153
- function buildCertPlan(domains) {
8154
- if (domains.length === 0) {
8155
- throw new Error("At least one domain is required.");
8156
- }
8157
- const primary = domains[0];
8158
- const certPath = join5(certDir(), `${primary}.pem`);
8159
- const keyPath = join5(certDir(), `${primary}-key.pem`);
8160
- const steps = [];
8161
- if (platform2() === "darwin") {
8162
- steps.push({
8163
- id: "mkcert-install-macos",
8164
- title: "Install mkcert on macOS",
8165
- command: "brew install mkcert nss",
8166
- manager: "brew"
8167
- });
8168
- } else {
8169
- steps.push({
8170
- id: "mkcert-install-linux",
8171
- title: "Install mkcert on Linux",
8172
- command: "sudo apt-get update && sudo apt-get install -y mkcert libnss3-tools",
8173
- manager: "apt",
8174
- privileged: true
8175
- });
8176
- }
8177
- steps.push({
8178
- id: "mkcert-local-ca",
8179
- title: "Install local mkcert CA",
8180
- command: "mkcert -install",
8181
- manager: "custom"
8182
- }, {
8183
- id: "mkcert-issue",
8184
- title: `Issue certificate for ${domains.join(", ")}`,
8185
- command: `mkdir -p ${quote3(certDir())} && mkcert -cert-file ${quote3(certPath)} -key-file ${quote3(keyPath)} ${domains.map((domain) => quote3(domain)).join(" ")}`,
8186
- 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
8187
8083
  });
8084
+ const selectedRoute = selectRouteHint(hints);
8085
+ const route = selectedRoute?.kind === "ssh" ? "ssh" : selectedRoute?.kind ?? "unknown";
8188
8086
  return {
8189
- machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
8190
- mode: "plan",
8191
- steps,
8192
- 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 ?? {}
8193
8111
  };
8194
8112
  }
8195
- function runCertPlan(domains, options = {}) {
8196
- const plan = buildCertPlan(domains);
8197
- if (!options.apply)
8198
- return plan;
8199
- if (!options.yes) {
8200
- throw new Error("Certificate generation requires --yes.");
8201
- }
8202
- let executed = 0;
8203
- for (const step of plan.steps) {
8204
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
8205
- stdout: "pipe",
8206
- stderr: "pipe",
8207
- 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)
8208
8137
  });
8209
- if (result.exitCode !== 0) {
8210
- throw new Error(`Certificate step failed (${step.id}): ${result.stderr.toString().trim()}`);
8211
- }
8212
- executed += 1;
8213
- }
8138
+ });
8214
8139
  return {
8215
- machineId: plan.machineId,
8216
- mode: "apply",
8217
- steps: plan.steps,
8218
- 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
8219
8153
  };
8220
8154
  }
8221
-
8222
- // src/commands/dns.ts
8223
- init_paths();
8224
- import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
8225
- import { join as join6 } from "path";
8226
- function getDnsPath() {
8227
- return join6(getDataDir(), "dns.json");
8228
- }
8229
- function readMappings() {
8230
- const path = getDnsPath();
8231
- if (!existsSync5(path))
8232
- return [];
8233
- return JSON.parse(readFileSync3(path, "utf8"));
8234
- }
8235
- function writeMappings(mappings) {
8236
- const path = getDnsPath();
8237
- ensureParentDir(path);
8238
- writeFileSync2(path, `${JSON.stringify(mappings, null, 2)}
8239
- `, "utf8");
8240
- return path;
8241
- }
8242
- function addDomainMapping(domain, port, targetHost = "127.0.0.1") {
8243
- const mappings = readMappings().filter((entry) => entry.domain !== domain);
8244
- mappings.push({ domain, port, targetHost });
8245
- writeMappings(mappings);
8246
- return mappings.sort((left, right) => left.domain.localeCompare(right.domain));
8155
+ function normalizeMachineAlias(value) {
8156
+ return value.trim().replace(/\.$/, "").toLowerCase();
8247
8157
  }
8248
- function listDomainMappings() {
8249
- 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);
8250
8170
  }
8251
- function renderDomainMapping(domain) {
8252
- const entry = readMappings().find((mapping) => mapping.domain === domain);
8253
- if (!entry) {
8254
- 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
+ };
8255
8178
  }
8256
- return {
8257
- hostsEntry: `${entry.targetHost} ${entry.domain}`,
8258
- caddySnippet: `${entry.domain} {
8259
- reverse_proxy 127.0.0.1:${entry.port}
8260
- tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
8261
- }`,
8262
- certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
8263
- keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
8264
- };
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 };
8265
8197
  }
8266
-
8267
- // src/commands/diff.ts
8268
- function packageNames(machine) {
8269
- 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";
8270
8210
  }
8271
- function fileTargets(machine) {
8272
- return (machine.files || []).map((file) => `${file.source}->${file.target}`).sort();
8211
+ function addMilliseconds(date, milliseconds) {
8212
+ return new Date(date.getTime() + milliseconds).toISOString();
8273
8213
  }
8274
- function diffMachines(leftMachineId, rightMachineId) {
8275
- const left = getManifestMachine(leftMachineId);
8276
- if (!left) {
8277
- throw new Error(`Machine not found in manifest: ${leftMachineId}`);
8278
- }
8279
- const right = rightMachineId ? getManifestMachine(rightMachineId) : detectCurrentMachineManifest();
8280
- if (!right) {
8281
- 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
+ };
8282
8308
  }
8283
- const changedFields = [
8284
- left.platform !== right.platform ? "platform" : null,
8285
- left.connection !== right.connection ? "connection" : null,
8286
- left.workspacePath !== right.workspacePath ? "workspacePath" : null,
8287
- left.bunPath !== right.bunPath ? "bunPath" : null
8288
- ].filter(Boolean);
8289
- const leftPackages = new Set(packageNames(left));
8290
- const rightPackages = new Set(packageNames(right));
8291
- const leftFiles = new Set(fileTargets(left));
8292
- 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);
8293
8314
  return {
8294
- leftMachineId: left.id,
8295
- rightMachineId: right.id,
8296
- changedFields,
8297
- missingPackages: {
8298
- leftOnly: [...leftPackages].filter((pkg) => !rightPackages.has(pkg)),
8299
- 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
8300
8334
  },
8301
- missingFiles: {
8302
- leftOnly: [...leftFiles].filter((file) => !rightFiles.has(file)),
8303
- rightOnly: [...rightFiles].filter((file) => !leftFiles.has(file))
8304
- }
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
8305
8345
  };
8306
8346
  }
8307
-
8308
- // src/remote.ts
8309
- init_db();
8310
- import { spawnSync as spawnSync2 } from "child_process";
8311
- import { hostname as hostname5 } from "os";
8312
-
8313
- // src/topology.ts
8314
- init_db();
8315
- import { existsSync as existsSync6 } from "fs";
8316
- import { arch as arch2, hostname as hostname4, platform as platform3, userInfo as userInfo2 } from "os";
8317
- import { spawnSync } from "child_process";
8318
- init_paths();
8319
- var MACHINES_CONSUMER_CONTRACT_VERSION = 1;
8320
- var MACHINES_PACKAGE_NAME = "@hasna/machines";
8321
- var DEFAULT_MACHINE_RESOLVER_TTL_MS = 24 * 60 * 60 * 1000;
8322
- var MACHINES_CONSUMER_CAPABILITIES = {
8323
- topology: true,
8324
- compatibility: true,
8325
- route_resolution: true,
8326
- cli_json_fallback: true,
8327
- workspace_path_mapping: true,
8328
- workspace_diagnostics: true,
8329
- schema_artifacts: true,
8330
- cacheability_metadata: true,
8331
- resolver_snapshots: true,
8332
- field_capability_descriptors: true
8333
- };
8334
- function getMachinesConsumerCapabilities() {
8335
- return { ...MACHINES_CONSUMER_CAPABILITIES };
8347
+ function isRecord(value) {
8348
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8336
8349
  }
8337
- function normalizePlatform2(value = platform3()) {
8338
- const normalized = value.toLowerCase();
8339
- if (normalized === "darwin" || normalized === "macos")
8340
- return "macos";
8341
- if (normalized === "win32" || normalized === "windows")
8342
- return "windows";
8343
- if (normalized === "linux")
8344
- return "linux";
8345
- 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;
8346
8357
  }
8347
- function defaultRunner(command) {
8348
- const result = spawnSync("bash", ["-c", command], {
8349
- encoding: "utf8",
8350
- env: process.env
8351
- });
8352
- return {
8353
- stdout: result.stdout || "",
8354
- stderr: result.stderr || "",
8355
- exitCode: result.status ?? 1
8356
- };
8357
- }
8358
- function hasCommand(command, runner) {
8359
- return runner(`command -v ${command} >/dev/null 2>&1`).exitCode === 0;
8360
- }
8361
- function parseTailscaleStatus(raw) {
8362
- try {
8363
- const parsed = JSON.parse(raw);
8364
- if (!parsed || typeof parsed !== "object")
8365
- return null;
8366
- return parsed;
8367
- } catch {
8368
- 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;
8369
8363
  }
8364
+ return null;
8370
8365
  }
8371
- function loadTailscalePeers(runner, warnings) {
8372
- const peers = new Map;
8373
- if (!hasCommand("tailscale", runner)) {
8374
- warnings.push("tailscale_not_available");
8375
- return peers;
8376
- }
8377
- const result = runner("tailscale status --json");
8378
- if (result.exitCode !== 0) {
8379
- warnings.push(`tailscale_status_failed:${result.stderr.trim() || result.exitCode}`);
8380
- return peers;
8381
- }
8382
- const status = parseTailscaleStatus(result.stdout);
8383
- if (!status) {
8384
- warnings.push("tailscale_status_invalid_json");
8385
- 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");
8386
8371
  }
8387
- const addPeer = (peer) => {
8388
- if (!peer)
8389
- return;
8390
- const id = peer.HostName || peer.DNSName?.split(".")[0];
8391
- if (id)
8392
- peers.set(id, peer);
8393
- };
8394
- addPeer(status.Self);
8395
- for (const peer of Object.values(status.Peer ?? {}))
8396
- addPeer(peer);
8397
- return peers;
8398
- }
8399
- function machineKeys(machine) {
8400
- return [
8401
- machine.id,
8402
- machine.hostname,
8403
- machine.tailscaleName?.split(".")[0],
8404
- machine.tailscaleName,
8405
- machine.sshAddress?.split("@").pop()
8406
- ].filter((value) => Boolean(value));
8372
+ return [];
8407
8373
  }
8408
- function findTailscalePeer(machine, machineId, peers) {
8409
- if (machine) {
8410
- for (const key of machineKeys(machine)) {
8411
- const peer = peers.get(key) ?? peers.get(key.replace(/\.$/, ""));
8412
- if (peer)
8413
- 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
+ }
8414
8388
  }
8415
8389
  }
8416
- return peers.get(machineId) ?? null;
8390
+ return null;
8417
8391
  }
8418
- function envReachableHosts() {
8419
- const raw = process.env["HASNA_MACHINES_REACHABLE_HOSTS"];
8420
- return new Set((raw || "").split(",").map((value) => value.trim()).filter(Boolean));
8392
+ function trimTrailingSlash(value) {
8393
+ return value.replace(/\/+$/, "");
8421
8394
  }
8422
- function manifestHostReachable(target) {
8423
- const overrides = envReachableHosts();
8424
- if (overrides.size === 0)
8425
- return null;
8426
- return overrides.has(target);
8395
+ function joinPath(left, right) {
8396
+ return `${trimTrailingSlash(left)}/${right.replace(/^\/+/, "")}`;
8427
8397
  }
8428
- function routeHints(input) {
8429
- const hints = [];
8430
- if (input.machineId === input.localMachineId) {
8431
- hints.push({ kind: "local", target: "localhost", reachable: true });
8432
- }
8433
- if (input.manifest?.sshAddress) {
8434
- hints.push({ kind: "ssh", target: input.manifest.sshAddress, reachable: manifestHostReachable(input.manifest.sshAddress) });
8435
- }
8436
- if (input.manifest?.hostname) {
8437
- hints.push({ kind: "lan", target: input.manifest.hostname, reachable: manifestHostReachable(input.manifest.hostname) });
8438
- }
8439
- const tailscaleTarget = input.manifest?.tailscaleName ?? input.peer?.DNSName ?? input.peer?.TailscaleIPs?.[0];
8440
- if (tailscaleTarget) {
8441
- 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}`);
8442
8406
  }
8443
- return hints;
8407
+ return joinPath(root, repoName);
8444
8408
  }
8445
- function routeRank(hint) {
8446
- if (hint.kind === "local")
8447
- return 0;
8448
- if (hint.reachable === true && hint.kind === "ssh")
8449
- return 1;
8450
- if (hint.reachable === true && hint.kind === "lan")
8451
- return 2;
8452
- if (hint.reachable === true && hint.kind === "tailscale")
8453
- return 3;
8454
- if (hint.reachable === false)
8455
- return 8;
8456
- if (hint.kind === "ssh")
8457
- return 4;
8458
- if (hint.kind === "lan")
8459
- return 5;
8460
- if (hint.kind === "tailscale")
8461
- return 6;
8462
- return 9;
8409
+ function shellQuote(value) {
8410
+ return `'${value.replace(/'/g, "'\\''")}'`;
8463
8411
  }
8464
- function selectRouteHint(hints) {
8465
- return [...hints].sort((left, right) => routeRank(left) - routeRank(right))[0] ?? null;
8412
+ function shellCommand(command) {
8413
+ return command.map(shellQuote).join(" ");
8466
8414
  }
8467
- function buildEntry(input) {
8468
- const manifest = input.manifest;
8469
- const peer = input.peer;
8470
- const hints = routeHints({
8471
- machineId: input.machineId,
8472
- localMachineId: input.localMachineId,
8473
- manifest,
8474
- peer
8475
- });
8476
- const selectedRoute = selectRouteHint(hints);
8477
- const route = selectedRoute?.kind === "ssh" ? "ssh" : selectedRoute?.kind ?? "unknown";
8478
- return {
8479
- machine_id: input.machineId,
8480
- hostname: manifest?.hostname ?? peer?.HostName ?? null,
8481
- platform: manifest?.platform ?? (peer?.OS ? normalizePlatform2(peer.OS) : null),
8482
- os: peer?.OS ?? null,
8483
- user: typeof manifest?.metadata?.user === "string" ? manifest.metadata.user : null,
8484
- workspace_path: manifest?.workspacePath ?? null,
8485
- manifest_declared: Boolean(manifest),
8486
- heartbeat_status: input.heartbeat?.status ?? "unknown",
8487
- last_heartbeat_at: input.heartbeat?.updated_at ?? null,
8488
- tailscale: {
8489
- dns_name: manifest?.tailscaleName ?? peer?.DNSName?.replace(/\.$/, "") ?? null,
8490
- ips: peer?.TailscaleIPs ?? [],
8491
- online: peer?.Online ?? null,
8492
- active: peer?.Active ?? null,
8493
- last_seen: peer?.LastSeen ?? null
8494
- },
8495
- ssh: {
8496
- address: manifest?.sshAddress ?? null,
8497
- route,
8498
- command_target: selectedRoute?.target ?? null
8499
- },
8500
- route_hints: hints,
8501
- tags: manifest?.tags ?? [],
8502
- metadata: manifest?.metadata ?? {}
8503
- };
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");
8504
8421
  }
8505
- function discoverMachineTopology(options = {}) {
8506
- const now2 = options.now ?? new Date;
8507
- const runner = options.runner ?? defaultRunner;
8508
- const warnings = [];
8509
- const manifest = readManifest();
8510
- const heartbeats = listHeartbeats();
8511
- const heartbeatByMachine = new Map(heartbeats.map((heartbeat) => [heartbeat.machine_id, heartbeat]));
8512
- const localMachineId = getLocalMachineId();
8513
- const peers = options.includeTailscale === false ? new Map : loadTailscalePeers(runner, warnings);
8514
- const machineIds = new Set([
8515
- localMachineId,
8516
- ...manifest.machines.map((machine) => machine.id),
8517
- ...heartbeats.map((heartbeat) => heartbeat.machine_id),
8518
- ...peers.keys()
8519
- ]);
8520
- const manifestById = new Map(manifest.machines.map((machine) => [machine.id, machine]));
8521
- const machines = [...machineIds].sort().map((machineId) => {
8522
- const manifestMachine = manifestById.get(machineId);
8523
- return buildEntry({
8524
- machineId,
8525
- localMachineId,
8526
- manifest: manifestMachine,
8527
- peer: findTailscalePeer(manifestMachine ?? null, machineId, peers),
8528
- heartbeat: heartbeatByMachine.get(machineId)
8529
- });
8530
- });
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"];
8531
8443
  return {
8532
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8533
- package: {
8534
- name: MACHINES_PACKAGE_NAME,
8535
- version: getPackageVersion()
8536
- },
8537
- capabilities: getMachinesConsumerCapabilities(),
8538
- generated_at: now2.toISOString(),
8539
- local_machine_id: localMachineId,
8540
- local_hostname: hostname4(),
8541
- current_platform: normalizePlatform2(),
8542
- manifest_path_known: existsSync6(getManifestPath()),
8543
- machines,
8544
- 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)
8545
8450
  };
8546
8451
  }
8547
- function normalizeMachineAlias(value) {
8548
- return value.trim().replace(/\.$/, "").toLowerCase();
8549
- }
8550
- function routeTargetMatches(machine, requested) {
8551
- const normalized = normalizeMachineAlias(requested);
8552
- const values = [
8553
- machine.ssh.address,
8554
- machine.ssh.command_target,
8555
- machine.tailscale.dns_name,
8556
- machine.tailscale.dns_name?.split(".")[0],
8557
- ...machine.tailscale.ips,
8558
- ...machine.route_hints.map((hint) => hint.target),
8559
- ...machine.route_hints.map((hint) => hint.target.split("@").pop() ?? hint.target)
8560
- ].filter((value) => Boolean(value));
8561
- return values.some((value) => normalizeMachineAlias(value) === normalized);
8562
- }
8563
- function findRouteMachine(topology, requestedMachineId) {
8564
- const requested = normalizeMachineAlias(requestedMachineId);
8565
- if (requested === "local" || requested === "localhost" || requested === normalizeMachineAlias(hostname4()) || requested === normalizeMachineAlias(topology.local_machine_id)) {
8452
+ function pathDiagnostic(input) {
8453
+ if (!input.path.path) {
8566
8454
  return {
8567
- machine: topology.machines.find((machine) => machine.machine_id === topology.local_machine_id) ?? null,
8568
- 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
8569
8462
  };
8570
8463
  }
8571
- const machineIdMatch = topology.machines.find((machine) => normalizeMachineAlias(machine.machine_id) === requested);
8572
- if (machineIdMatch)
8573
- return { machine: machineIdMatch, matchedBy: "machine_id" };
8574
- const hostnameMatch = topology.machines.find((machine) => machine.hostname && normalizeMachineAlias(machine.hostname) === requested);
8575
- if (hostnameMatch)
8576
- return { machine: hostnameMatch, matchedBy: "hostname" };
8577
- const tailscaleMatch = topology.machines.find((machine) => {
8578
- if (!machine.tailscale.dns_name)
8579
- return false;
8580
- const dns = normalizeMachineAlias(machine.tailscale.dns_name);
8581
- return dns === requested || dns.split(".")[0] === requested;
8582
- });
8583
- if (tailscaleMatch)
8584
- return { machine: tailscaleMatch, matchedBy: "tailscale" };
8585
- const routeMatch = topology.machines.find((machine) => routeTargetMatches(machine, requestedMachineId));
8586
- if (routeMatch)
8587
- return { machine: routeMatch, matchedBy: "route_target" };
8588
- return { machine: null, matchedBy: null };
8589
- }
8590
- function routeConfidence(input) {
8591
- if (input.matchedBy === "local_alias")
8592
- return "exact";
8593
- if (input.hint?.kind === "local")
8594
- return "exact";
8595
- if (input.hint?.reachable === true)
8596
- return "high";
8597
- if (input.machine.manifest_declared && (input.hint?.kind === "ssh" || input.hint?.kind === "lan"))
8598
- return "medium";
8599
- if (input.hint)
8600
- return "low";
8601
- return "none";
8602
- }
8603
- function addMilliseconds(date, milliseconds) {
8604
- return new Date(date.getTime() + milliseconds).toISOString();
8605
- }
8606
- function routeAuthority(input) {
8607
- if (!input.machine)
8608
- return "unresolved";
8609
- if (input.matchedBy === "fallback")
8610
- return "fallback";
8611
- if (input.selectedHint?.kind === "local")
8612
- return "live_topology";
8613
- if (input.selectedHint?.kind === "tailscale" || input.machine.tailscale.online !== null)
8614
- return "live_topology";
8615
- if (input.machine.manifest_declared)
8616
- return "manifest";
8617
- return "open-machines";
8618
- }
8619
- function workspaceAuthority(paths) {
8620
- const sources = [paths.workspace_root.source, paths.project_root.source, paths.open_files_root.source];
8621
- if (sources.some((source) => source === "argument"))
8622
- return "argument";
8623
- if (sources.some((source) => source === "manifest_metadata"))
8624
- return "manifest_metadata";
8625
- if (sources.some((source) => source === "manifest"))
8626
- return "manifest";
8627
- if (sources.some((source) => source === "inferred"))
8628
- return "inferred";
8629
- if (sources.every((source) => source === "unresolved"))
8630
- return "unresolved";
8631
- return "open-machines";
8632
- }
8633
- function cacheability(input) {
8634
- const ttlMs = input.ttlMs === undefined ? DEFAULT_MACHINE_RESOLVER_TTL_MS : input.ttlMs;
8635
- const expiresAt = typeof ttlMs === "number" && ttlMs > 0 ? addMilliseconds(input.observedAt, ttlMs) : null;
8636
- const stale = expiresAt ? input.now.getTime() > new Date(expiresAt).getTime() : false;
8637
- const confidenceCacheable = input.confidence !== "none" && input.confidence !== "low";
8638
- const cacheable = input.ok && confidenceCacheable && !stale && input.authority !== "unresolved";
8639
- const reasons = [...input.reasons];
8640
- if (!input.ok)
8641
- reasons.push("resolver_not_ok");
8642
- if (!confidenceCacheable)
8643
- reasons.push(`low_confidence:${input.confidence}`);
8644
- if (stale)
8645
- reasons.push("stale");
8646
- if (input.authority === "unresolved")
8647
- reasons.push("unresolved_authority");
8648
- return {
8649
- observed_at: input.observedAt.toISOString(),
8650
- verified_at: input.ok ? input.now.toISOString() : null,
8651
- expires_at: expiresAt,
8652
- ttl_ms: typeof ttlMs === "number" && ttlMs > 0 ? ttlMs : null,
8653
- source_authority: input.authority,
8654
- confidence: input.confidence,
8655
- cacheable,
8656
- stale,
8657
- reasons: [...new Set(reasons)].sort()
8658
- };
8659
- }
8660
- function resolveMachineRoute(machineId, options = {}) {
8661
- const now2 = options.now ?? new Date;
8662
- const topology = options.topology ?? discoverMachineTopology(options);
8663
- const warnings = [...topology.warnings];
8664
- const { machine, matchedBy } = findRouteMachine(topology, machineId);
8665
- const generatedAt = now2.toISOString();
8666
- if (!machine) {
8667
- warnings.push(`machine_not_found:${machineId}`);
8464
+ if (input.pathExists === false) {
8668
8465
  return {
8669
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8670
- package: { name: MACHINES_PACKAGE_NAME, version: getPackageVersion() },
8671
- ok: false,
8672
- machine_id: null,
8673
- requested_machine_id: machineId,
8674
- generated_at: generatedAt,
8675
- route: "unknown",
8676
- source: "unknown",
8677
- target: null,
8678
- command_target: null,
8679
- confidence: "none",
8680
- local: false,
8681
- evidence: {
8682
- topology: true,
8683
- matched_by: null,
8684
- manifest_declared: null,
8685
- heartbeat_status: null,
8686
- tailscale_online: null,
8687
- selected_hint: null
8688
- },
8689
- cacheability: cacheability({
8690
- ok: false,
8691
- observedAt: now2,
8692
- now: now2,
8693
- ttlMs: options.resolverTtlMs,
8694
- authority: "unresolved",
8695
- confidence: "none",
8696
- reasons: [`machine_not_found:${machineId}`]
8697
- }),
8698
- 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
8699
8484
  };
8700
8485
  }
8701
- const selectedHint = selectRouteHint(machine.route_hints);
8702
- const route = selectedHint?.kind ?? machine.ssh.route ?? "unknown";
8703
- const local = route === "local" || machine.machine_id === topology.local_machine_id;
8704
- const confidence = routeConfidence({ machine, hint: selectedHint, matchedBy });
8705
- const ok = Boolean(selectedHint?.target);
8706
8486
  return {
8707
- schema_version: MACHINES_CONSUMER_CONTRACT_VERSION,
8708
- package: topology.package,
8709
- ok,
8710
- machine_id: machine.machine_id,
8711
- requested_machine_id: machineId,
8712
- generated_at: generatedAt,
8713
- route,
8714
- source: route,
8715
- target: selectedHint?.target ?? null,
8716
- command_target: selectedHint?.target ?? null,
8717
- confidence,
8718
- local,
8719
- evidence: {
8720
- topology: true,
8721
- matched_by: matchedBy,
8722
- manifest_declared: machine.manifest_declared,
8723
- heartbeat_status: machine.heartbeat_status,
8724
- tailscale_online: machine.tailscale.online,
8725
- selected_hint: selectedHint
8726
- },
8727
- cacheability: cacheability({
8728
- ok,
8729
- observedAt: now2,
8730
- now: now2,
8731
- ttlMs: options.resolverTtlMs,
8732
- authority: routeAuthority({ machine, selectedHint, matchedBy }),
8733
- confidence,
8734
- reasons: selectedHint ? [] : ["route_target_unresolved"]
8735
- }),
8736
- 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
8737
8494
  };
8738
8495
  }
8739
- function isRecord(value) {
8740
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8741
- }
8742
- function metadataString(metadata, keys) {
8743
- for (const key of keys) {
8744
- const value = metadata[key];
8745
- if (typeof value === "string" && value.trim())
8746
- return value.trim();
8747
- }
8748
- return null;
8749
- }
8750
- function metadataBoolean(metadata, keys) {
8751
- for (const key of keys) {
8752
- const value = metadata[key];
8753
- if (typeof value === "boolean")
8754
- return value;
8755
- }
8756
- return null;
8757
- }
8758
- function metadataStringArray(metadata, keys) {
8759
- for (const key of keys) {
8760
- const value = metadata[key];
8761
- if (Array.isArray(value))
8762
- return value.filter((entry) => typeof entry === "string");
8763
- }
8764
- return [];
8765
- }
8766
- function readMappedPath(input) {
8767
- for (const containerName of input.containers) {
8768
- const container = input.metadata[containerName];
8769
- if (!isRecord(container))
8770
- continue;
8771
- for (const key of input.keys) {
8772
- const value = container[key];
8773
- if (typeof value === "string" && value.trim())
8774
- return value.trim();
8775
- if (isRecord(value)) {
8776
- const path = metadataString(value, ["path", "root", "workspacePath", "workspace_path"]);
8777
- if (path)
8778
- return path;
8779
- }
8780
- }
8781
- }
8782
- return null;
8783
- }
8784
- function trimTrailingSlash(value) {
8785
- return value.replace(/\/+$/, "");
8786
- }
8787
- function joinPath(left, right) {
8788
- return `${trimTrailingSlash(left)}/${right.replace(/^\/+/, "")}`;
8789
- }
8790
- function inferRepoRoot(workspaceRoot, repoName) {
8791
- if (!workspaceRoot || !repoName)
8792
- return null;
8793
- const root = trimTrailingSlash(workspaceRoot);
8794
- if (root.endsWith(`/${repoName}`) || root === repoName)
8795
- return root;
8796
- if (root.endsWith("/workspace") || root.endsWith("/Workspace")) {
8797
- return joinPath(root, `hasna/opensource/${repoName}`);
8798
- }
8799
- return joinPath(root, repoName);
8800
- }
8801
- function shellQuote(value) {
8802
- return `'${value.replace(/'/g, "'\\''")}'`;
8803
- }
8804
- function shellCommand(command) {
8805
- return command.map(shellQuote).join(" ");
8806
- }
8807
- function canCheckPathForMachine(machine, localMachineId) {
8808
- if (!machine)
8809
- return false;
8810
- if (machine.machine_id === localMachineId)
8811
- return true;
8812
- return machine.route_hints.some((hint) => hint.kind === "local");
8813
- }
8814
- function checkedPathExists(path, check) {
8815
- if (!path || !check)
8816
- return null;
8817
- return existsSync6(path);
8818
- }
8819
- function repairHint(input) {
8820
- const command = [
8821
- "machines",
8822
- "workspace",
8823
- "repair",
8824
- "--machine",
8825
- input.machineId,
8826
- "--project",
8827
- input.projectId
8828
- ];
8829
- if (input.repoName)
8830
- command.push("--repo", input.repoName);
8831
- if (input.openFilesRepoName)
8832
- command.push("--open-files-repo", input.openFilesRepoName);
8833
- command.push("--json");
8834
- const applyCommand = [...command.slice(0, -1), "--apply", "--json"];
8835
- return {
8836
- id: `repair:${input.machineId}:${input.projectId}`,
8837
- reason: input.reason,
8838
- command,
8839
- shell_command: shellCommand(command),
8840
- apply_command: applyCommand,
8841
- apply_shell_command: shellCommand(applyCommand)
8842
- };
8843
- }
8844
- function pathDiagnostic(input) {
8845
- if (!input.path.path) {
8846
- return {
8847
- id: input.id,
8848
- status: "missing",
8849
- severity: input.required ? "fail" : "warn",
8850
- message: `${input.label} is unresolved.`,
8851
- path: null,
8852
- source: input.path.source,
8853
- path_exists: null
8854
- };
8855
- }
8856
- if (input.pathExists === false) {
8857
- return {
8858
- id: input.id,
8859
- status: "stale",
8860
- severity: "fail",
8861
- message: `${input.label} points to a path that does not exist on this machine.`,
8862
- path: input.path.path,
8863
- source: input.path.source,
8864
- path_exists: false
8865
- };
8866
- }
8867
- if (input.path.source === "inferred") {
8868
- return {
8869
- id: input.id,
8870
- status: "inferred",
8871
- severity: "warn",
8872
- message: `${input.label} was inferred from the workspace root; write an explicit manifest mapping for repeatable downstream sync.`,
8873
- path: input.path.path,
8874
- source: input.path.source,
8875
- path_exists: input.pathExists
8876
- };
8877
- }
8878
- return {
8879
- id: input.id,
8880
- status: "ok",
8881
- severity: "ok",
8882
- message: `${input.label} is explicit enough for downstream sync.`,
8883
- path: input.path.path,
8884
- source: input.path.source,
8885
- path_exists: input.pathExists
8886
- };
8887
- }
8888
- function workspaceDiagnostics(input) {
8889
- const checkPaths = canCheckPathForMachine(input.machine, input.localMachineId);
8890
- const diagnostics = [];
8891
- if (!input.machine) {
8892
- diagnostics.push({
8893
- id: "manifest",
8894
- status: "missing_manifest",
8895
- severity: "fail",
8896
- message: "Machine is not present in topology or manifest.",
8897
- path: null,
8898
- source: "manifest",
8899
- path_exists: null
8900
- });
8901
- } else if (!input.resolution.evidence.manifest_declared) {
8902
- diagnostics.push({
8903
- id: "manifest",
8904
- status: "missing_manifest",
8905
- severity: "warn",
8906
- message: "Machine came from live topology but is not declared in the manifest.",
8907
- path: null,
8908
- source: "manifest",
8909
- path_exists: null
8910
- });
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
+ });
8911
8519
  }
8912
8520
  diagnostics.push(pathDiagnostic({
8913
8521
  id: "workspace_root",
@@ -9144,80 +8752,478 @@ function resolveMachineWorkspace(options) {
9144
8752
  }),
9145
8753
  warnings
9146
8754
  };
9147
- const diagnostics = workspaceDiagnostics({
9148
- machine,
9149
- localMachineId: topology.local_machine_id,
9150
- resolution,
9151
- openFilesRepoName
9152
- });
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
+ }
9153
9058
  return {
9154
- ...resolution,
9155
- diagnostics: diagnostics.diagnostics,
9156
- repair_hints: diagnostics.repairHints
9059
+ machineId: plan.machineId,
9060
+ mode: "apply",
9061
+ steps: plan.steps,
9062
+ executed
9157
9063
  };
9158
9064
  }
9159
9065
 
9160
- // src/commands/ssh.ts
9161
- function shellQuote2(value) {
9162
- 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, `'\\''`)}'`;
9163
9071
  }
9164
- function resolveSshTarget(machineId, options = {}) {
9165
- const resolved = resolveMachineRoute(machineId, options);
9166
- if (!resolved.ok || !resolved.target) {
9167
- 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.");
9168
9078
  }
9169
- if (resolved.route !== "local" && resolved.route !== "lan" && resolved.route !== "tailscale" && resolved.route !== "ssh") {
9170
- 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
+ });
9171
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
+ });
9172
9110
  return {
9173
- machineId: resolved.machine_id ?? machineId,
9174
- target: resolved.target,
9175
- route: resolved.route,
9176
- confidence: resolved.confidence,
9177
- warnings: resolved.warnings
9111
+ machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
9112
+ mode: "plan",
9113
+ steps,
9114
+ executed: 0
9178
9115
  };
9179
9116
  }
9180
- function buildSshCommand(machineId, remoteCommand, options = {}) {
9181
- const resolved = resolveSshTarget(machineId, options);
9182
- 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
+ };
9183
9142
  }
9184
9143
 
9185
- // src/remote.ts
9186
- function shellQuote3(value) {
9187
- 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");
9188
9150
  }
9189
- function machineIsLocal(machineId, localMachineId) {
9190
- 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"));
9191
9156
  }
9192
- function resolveMachineCommand(machineId, command, localMachineId = getLocalMachineId()) {
9193
- if (machineIsLocal(machineId, localMachineId)) {
9194
- return { source: "local", shellCommand: command };
9195
- }
9196
- try {
9197
- return {
9198
- source: resolveSshTarget(machineId).route,
9199
- shellCommand: buildSshCommand(machineId, command)
9200
- };
9201
- } catch (error) {
9202
- const message = String(error.message ?? error);
9203
- if (message.includes("Machine route not found") || message.includes("Machine not found in manifest")) {
9204
- return { source: "ssh", shellCommand: `ssh ${shellQuote3(machineId)} ${shellQuote3(command)}` };
9205
- }
9206
- 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}`);
9207
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
+ };
9208
9187
  }
9209
- function runMachineCommand(machineId, command) {
9210
- const resolved = resolveMachineCommand(machineId, command);
9211
- const result = spawnSync2("bash", ["-c", resolved.shellCommand], {
9212
- encoding: "utf8",
9213
- env: process.env
9214
- });
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));
9215
9215
  return {
9216
- machineId,
9217
- source: resolved.source,
9218
- stdout: result.stdout || "",
9219
- stderr: result.stderr || "",
9220
- 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
+ }
9221
9227
  };
9222
9228
  }
9223
9229
 
@@ -9313,27 +9319,28 @@ function buildAppsPlan(machineId) {
9313
9319
  executed: 0
9314
9320
  };
9315
9321
  }
9316
- function getAppsStatus(machineId) {
9322
+ function getAppsStatus(machineId, runner = runMachineCommand) {
9317
9323
  const machine = resolveMachine(machineId);
9324
+ const readiness = requireMachineCommandSuccess("Apps status readiness check", runner(machine.id, "true"));
9318
9325
  const apps = (machine.apps || []).map((app) => {
9319
- const probe = runMachineCommand(machine.id, buildAppProbeCommand(machine, app));
9326
+ const probe = requireMachineCommandSuccess(`App probe ${app.name}`, runner(machine.id, buildAppProbeCommand(machine, app)));
9320
9327
  return parseProbeOutput(app, machine, probe.stdout);
9321
9328
  });
9322
9329
  return {
9323
9330
  machineId: machine.id,
9324
- source: apps.length > 0 ? runMachineCommand(machine.id, "true").source : machine.id === detectCurrentMachineManifest().id ? "local" : runMachineCommand(machine.id, "true").source,
9331
+ source: readiness.source,
9325
9332
  apps
9326
9333
  };
9327
9334
  }
9328
- function diffApps(machineId) {
9329
- const status = getAppsStatus(machineId);
9335
+ function diffApps(machineId, runner = runMachineCommand) {
9336
+ const status = getAppsStatus(machineId, runner);
9330
9337
  return {
9331
9338
  ...status,
9332
9339
  missing: status.apps.filter((app) => !app.installed).map((app) => app.name),
9333
9340
  installed: status.apps.filter((app) => app.installed).map((app) => app.name)
9334
9341
  };
9335
9342
  }
9336
- function runAppsInstall(machineId, options = {}) {
9343
+ function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
9337
9344
  const plan = buildAppsPlan(machineId);
9338
9345
  if (!options.apply)
9339
9346
  return plan;
@@ -9342,14 +9349,7 @@ function runAppsInstall(machineId, options = {}) {
9342
9349
  }
9343
9350
  let executed = 0;
9344
9351
  for (const step of plan.steps) {
9345
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
9346
- stdout: "pipe",
9347
- stderr: "pipe",
9348
- env: process.env
9349
- });
9350
- if (result.exitCode !== 0) {
9351
- throw new Error(`App install failed (${step.id}): ${result.stderr.toString().trim()}`);
9352
- }
9352
+ requireMachineCommandSuccess(`App install ${step.id}`, runner(plan.machineId, step.command));
9353
9353
  executed += 1;
9354
9354
  }
9355
9355
  return {
@@ -9420,25 +9420,28 @@ function buildClaudeInstallPlan(machineId, tools) {
9420
9420
  executed: 0
9421
9421
  };
9422
9422
  }
9423
- function getClaudeCliStatus(machineId, tools) {
9423
+ function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
9424
9424
  const machine = resolveMachine2(machineId);
9425
9425
  const normalizedTools = normalizeTools(tools);
9426
- const route = runMachineCommand(machine.id, "true").source;
9426
+ const route = requireMachineCommandSuccess("AI CLI status readiness check", runner(machine.id, "true")).source;
9427
9427
  return {
9428
9428
  machineId: machine.id,
9429
9429
  source: route,
9430
- 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
+ })
9431
9434
  };
9432
9435
  }
9433
- function diffClaudeCli(machineId, tools) {
9434
- const status = getClaudeCliStatus(machineId, tools);
9436
+ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
9437
+ const status = getClaudeCliStatus(machineId, tools, runner);
9435
9438
  return {
9436
9439
  ...status,
9437
9440
  missing: status.tools.filter((tool) => !tool.installed).map((tool) => tool.tool),
9438
9441
  installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
9439
9442
  };
9440
9443
  }
9441
- function runClaudeInstall(machineId, tools, options = {}) {
9444
+ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCommand) {
9442
9445
  const plan = buildClaudeInstallPlan(machineId, tools);
9443
9446
  if (!options.apply)
9444
9447
  return plan;
@@ -9447,14 +9450,7 @@ function runClaudeInstall(machineId, tools, options = {}) {
9447
9450
  }
9448
9451
  let executed = 0;
9449
9452
  for (const step of plan.steps) {
9450
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
9451
- stdout: "pipe",
9452
- stderr: "pipe",
9453
- env: process.env
9454
- });
9455
- if (result.exitCode !== 0) {
9456
- throw new Error(`AI CLI install failed (${step.id}): ${result.stderr.toString().trim()}`);
9457
- }
9453
+ requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
9458
9454
  executed += 1;
9459
9455
  }
9460
9456
  return {
@@ -9506,7 +9502,7 @@ function buildTailscaleInstallPlan(machineId) {
9506
9502
  executed: 0
9507
9503
  };
9508
9504
  }
9509
- function runTailscaleInstall(machineId, options = {}) {
9505
+ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand) {
9510
9506
  const plan = buildTailscaleInstallPlan(machineId);
9511
9507
  if (!options.apply)
9512
9508
  return plan;
@@ -9515,14 +9511,7 @@ function runTailscaleInstall(machineId, options = {}) {
9515
9511
  }
9516
9512
  let executed = 0;
9517
9513
  for (const step of plan.steps) {
9518
- const result = Bun.spawnSync(["bash", "-lc", step.command], {
9519
- stdout: "pipe",
9520
- stderr: "pipe",
9521
- env: process.env
9522
- });
9523
- if (result.exitCode !== 0) {
9524
- throw new Error(`Tailscale install failed (${step.id}): ${result.stderr.toString().trim()}`);
9525
- }
9514
+ requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
9526
9515
  executed += 1;
9527
9516
  }
9528
9517
  return {
@@ -10563,16 +10552,17 @@ function quote4(value) {
10563
10552
  return `'${value.replace(/'/g, `'\\''`)}'`;
10564
10553
  }
10565
10554
  function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
10555
+ const quotedPackageName = quote4(packageName);
10566
10556
  if (manager === "bun") {
10567
- 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`;
10568
10558
  }
10569
10559
  if (manager === "brew") {
10570
- 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`;
10571
10561
  }
10572
10562
  if (manager === "apt") {
10573
- 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`;
10574
10564
  }
10575
- 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`;
10576
10566
  }
10577
10567
  function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
10578
10568
  if (manager === "bun") {
@@ -10586,15 +10576,15 @@ function packageInstallCommand(machine, packageName, manager = machine.platform
10586
10576
  }
10587
10577
  return packageName;
10588
10578
  }
10589
- function detectPackageActions(machine) {
10579
+ function detectPackageActions(machine, runner) {
10590
10580
  return (machine.packages || []).map((pkg, index) => {
10591
10581
  const manager = pkg.manager || (machine.platform === "macos" ? "brew" : "apt");
10592
- const check = Bun.spawnSync(["bash", "-lc", packageCheckCommand(machine, pkg.name, manager)], {
10593
- stdout: "ignore",
10594
- stderr: "ignore",
10595
- env: process.env
10596
- });
10597
- 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");
10598
10588
  return {
10599
10589
  id: `package-${index + 1}`,
10600
10590
  title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
@@ -10605,6 +10595,9 @@ function detectPackageActions(machine) {
10605
10595
  });
10606
10596
  }
10607
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
+ }
10608
10601
  return (machine.files || []).map((file, index) => {
10609
10602
  const sourceExists = existsSync9(file.source);
10610
10603
  const targetExists = existsSync9(file.target);
@@ -10628,7 +10621,7 @@ function detectFileActions(machine) {
10628
10621
  };
10629
10622
  });
10630
10623
  }
10631
- function buildSyncPlan(machineId) {
10624
+ function buildSyncPlan(machineId, runner = runMachineCommand) {
10632
10625
  const manifest = readManifest();
10633
10626
  const currentMachineId = getLocalMachineId();
10634
10627
  const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
@@ -10638,7 +10631,7 @@ function buildSyncPlan(machineId) {
10638
10631
  workspacePath: `${homedir7()}/workspace`
10639
10632
  };
10640
10633
  const actions = [
10641
- ...detectPackageActions(target),
10634
+ ...detectPackageActions(target, runner),
10642
10635
  ...detectFileActions(target)
10643
10636
  ];
10644
10637
  return {
@@ -10668,8 +10661,8 @@ function applyFileAction(command) {
10668
10661
  symlinkSync(sourcePath, targetPath);
10669
10662
  }
10670
10663
  }
10671
- function runSync(machineId, options = {}) {
10672
- const plan = buildSyncPlan(machineId);
10664
+ function runSync(machineId, options = {}, runner = runMachineCommand) {
10665
+ const plan = buildSyncPlan(machineId, runner);
10673
10666
  if (!options.apply) {
10674
10667
  return plan;
10675
10668
  }
@@ -10683,18 +10676,17 @@ function runSync(machineId, options = {}) {
10683
10676
  if (action.kind === "file") {
10684
10677
  applyFileAction(action.command);
10685
10678
  } else {
10686
- const result = Bun.spawnSync(["bash", "-lc", action.command], {
10687
- stdout: "pipe",
10688
- stderr: "pipe",
10689
- env: process.env
10690
- });
10679
+ const result = runner(plan.machineId, action.command);
10691
10680
  if (result.exitCode !== 0) {
10692
10681
  recordSyncRun(plan.machineId, "failed", {
10693
10682
  executed,
10694
10683
  failedAction: action,
10695
- stderr: result.stderr.toString()
10684
+ stderr: result.stderr,
10685
+ stdout: result.stdout,
10686
+ exitCode: result.exitCode,
10687
+ source: result.source
10696
10688
  });
10697
- throw new Error(`Sync action failed (${action.id}): ${result.stderr.toString().trim()}`);
10689
+ throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
10698
10690
  }
10699
10691
  }
10700
10692
  executed += 1;