@hasna/machines 0.0.30 → 0.0.32

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",
@@ -9151,73 +8759,474 @@ function resolveMachineWorkspace(options) {
9151
8759
  openFilesRepoName
9152
8760
  });
9153
8761
  return {
9154
- ...resolution,
9155
- diagnostics: diagnostics.diagnostics,
9156
- repair_hints: diagnostics.repairHints
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
+ if (machineId && !selected) {
8911
+ throw new Error(`Machine not found in manifest: ${machineId}`);
8912
+ }
8913
+ const target = selected || {
8914
+ id: currentMachineId,
8915
+ platform: "linux",
8916
+ workspacePath: `${homedir3()}/workspace`
8917
+ };
8918
+ const steps = [...buildBaseSteps(target), ...buildPackageSteps(target)];
8919
+ return {
8920
+ machineId: target.id,
8921
+ mode: "plan",
8922
+ steps,
8923
+ executed: 0
8924
+ };
8925
+ }
8926
+ function runSetup(machineId, options = {}, runner = runMachineCommand) {
8927
+ const plan = buildSetupPlan(machineId);
8928
+ if (!options.apply) {
8929
+ return plan;
8930
+ }
8931
+ if (!options.yes) {
8932
+ throw new Error("Setup execution requires --yes.");
8933
+ }
8934
+ let executed = 0;
8935
+ for (const step of plan.steps) {
8936
+ const result = runner(plan.machineId, step.command);
8937
+ if (result.exitCode !== 0) {
8938
+ recordSetupRun(plan.machineId, "failed", {
8939
+ executed,
8940
+ failedStep: step,
8941
+ stderr: result.stderr,
8942
+ stdout: result.stdout,
8943
+ exitCode: result.exitCode,
8944
+ source: result.source
8945
+ });
8946
+ throw new Error(describeMachineCommandFailure(`Setup step ${step.id}`, result));
8947
+ }
8948
+ executed += 1;
8949
+ }
8950
+ const summary = {
8951
+ machineId: plan.machineId,
8952
+ mode: "apply",
8953
+ steps: plan.steps,
8954
+ executed
8955
+ };
8956
+ recordSetupRun(plan.machineId, "completed", summary);
8957
+ return summary;
8958
+ }
8959
+
8960
+ // src/commands/backup.ts
8961
+ import { homedir as homedir4, hostname as hostname5 } from "os";
8962
+ import { join as join4 } from "path";
8963
+ var MACHINES_BACKUP_BUCKET_ENV = "HASNA_MACHINES_S3_BUCKET";
8964
+ var MACHINES_BACKUP_BUCKET_FALLBACK_ENV = "MACHINES_S3_BUCKET";
8965
+ var MACHINES_BACKUP_PREFIX_ENV = "HASNA_MACHINES_S3_PREFIX";
8966
+ var MACHINES_BACKUP_PREFIX_FALLBACK_ENV = "MACHINES_S3_PREFIX";
8967
+ var DEFAULT_BACKUP_PREFIX = "machines";
8968
+ function quote2(value) {
8969
+ return `'${value.replace(/'/g, `'\\''`)}'`;
8970
+ }
8971
+ function readEnv(name) {
8972
+ const value = process.env[name]?.trim();
8973
+ return value || undefined;
8974
+ }
8975
+ function readBackupBucketEnv() {
8976
+ const primary = readEnv(MACHINES_BACKUP_BUCKET_ENV);
8977
+ if (primary)
8978
+ return { bucket: primary, bucketSource: MACHINES_BACKUP_BUCKET_ENV };
8979
+ const fallback = readEnv(MACHINES_BACKUP_BUCKET_FALLBACK_ENV);
8980
+ if (fallback)
8981
+ return { bucket: fallback, bucketSource: MACHINES_BACKUP_BUCKET_FALLBACK_ENV };
8982
+ return null;
8983
+ }
8984
+ function readBackupPrefixEnv() {
8985
+ const primary = readEnv(MACHINES_BACKUP_PREFIX_ENV);
8986
+ if (primary)
8987
+ return { prefix: primary, prefixSource: MACHINES_BACKUP_PREFIX_ENV };
8988
+ const fallback = readEnv(MACHINES_BACKUP_PREFIX_FALLBACK_ENV);
8989
+ if (fallback)
8990
+ return { prefix: fallback, prefixSource: MACHINES_BACKUP_PREFIX_FALLBACK_ENV };
8991
+ return null;
8992
+ }
8993
+ function resolveBackupTarget(options = {}) {
8994
+ const explicitBucket = options.bucket?.trim();
8995
+ const envBucket = explicitBucket ? null : readBackupBucketEnv();
8996
+ const bucket = explicitBucket || envBucket?.bucket;
8997
+ if (!bucket) {
8998
+ throw new Error(`Missing S3 backup bucket. Pass --bucket or set ${MACHINES_BACKUP_BUCKET_ENV} or ${MACHINES_BACKUP_BUCKET_FALLBACK_ENV}.`);
8999
+ }
9000
+ const explicitPrefix = options.prefix?.trim();
9001
+ const envPrefix = explicitPrefix ? null : readBackupPrefixEnv();
9002
+ return {
9003
+ bucket,
9004
+ prefix: explicitPrefix || envPrefix?.prefix || DEFAULT_BACKUP_PREFIX,
9005
+ bucketSource: explicitBucket ? "argument" : envBucket.bucketSource,
9006
+ prefixSource: explicitPrefix ? "argument" : envPrefix?.prefixSource || "default"
9007
+ };
9008
+ }
9009
+ function defaultBackupSources() {
9010
+ const home = homedir4();
9011
+ return [
9012
+ join4(home, ".hasna"),
9013
+ join4(home, ".ssh"),
9014
+ join4(home, ".secrets")
9015
+ ];
9016
+ }
9017
+ function buildBackupPlan(bucket, prefix) {
9018
+ const target = resolveBackupTarget({ bucket, prefix });
9019
+ const archivePath = join4(homedir4(), ".hasna", "machines", "backup.tgz");
9020
+ const sources = defaultBackupSources();
9021
+ const steps = [
9022
+ {
9023
+ id: "backup-archive",
9024
+ title: "Create compressed machine backup archive",
9025
+ command: `tar -czf ${quote2(archivePath)} ${sources.map((source) => quote2(source)).join(" ")}`,
9026
+ manager: "shell"
9027
+ },
9028
+ {
9029
+ id: "backup-upload",
9030
+ title: "Upload archive to S3",
9031
+ command: `aws s3 cp ${quote2(archivePath)} ${quote2(`s3://${target.bucket}/${target.prefix}/${hostname5()}-backup.tgz`)}`,
9032
+ manager: "custom"
9033
+ }
9034
+ ];
9035
+ return {
9036
+ machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
9037
+ mode: "plan",
9038
+ steps,
9039
+ executed: 0
9040
+ };
9041
+ }
9042
+ function runBackup(bucket, prefix, options = {}) {
9043
+ const plan = buildBackupPlan(bucket, prefix);
9044
+ if (!options.apply)
9045
+ return plan;
9046
+ if (!options.yes) {
9047
+ throw new Error("Backup execution requires --yes.");
9048
+ }
9049
+ let executed = 0;
9050
+ for (const step of plan.steps) {
9051
+ const result = Bun.spawnSync(["bash", "-lc", step.command], {
9052
+ stdout: "pipe",
9053
+ stderr: "pipe",
9054
+ env: process.env
9055
+ });
9056
+ if (result.exitCode !== 0) {
9057
+ throw new Error(`Backup step failed (${step.id}): ${result.stderr.toString().trim()}`);
9058
+ }
9059
+ executed += 1;
9060
+ }
9061
+ return {
9062
+ machineId: plan.machineId,
9063
+ mode: "apply",
9064
+ steps: plan.steps,
9065
+ executed
9157
9066
  };
9158
9067
  }
9159
9068
 
9160
- // src/commands/ssh.ts
9161
- function shellQuote2(value) {
9162
- return `'${value.replace(/'/g, "'\\''")}'`;
9069
+ // src/commands/cert.ts
9070
+ import { homedir as homedir5, platform as platform3 } from "os";
9071
+ import { join as join5 } from "path";
9072
+ function quote3(value) {
9073
+ return `'${value.replace(/'/g, `'\\''`)}'`;
9163
9074
  }
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}`);
9075
+ function certDir() {
9076
+ return join5(homedir5(), ".hasna", "machines", "certs");
9077
+ }
9078
+ function buildCertPlan(domains) {
9079
+ if (domains.length === 0) {
9080
+ throw new Error("At least one domain is required.");
9168
9081
  }
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}`);
9082
+ const primary = domains[0];
9083
+ const certPath = join5(certDir(), `${primary}.pem`);
9084
+ const keyPath = join5(certDir(), `${primary}-key.pem`);
9085
+ const steps = [];
9086
+ if (platform3() === "darwin") {
9087
+ steps.push({
9088
+ id: "mkcert-install-macos",
9089
+ title: "Install mkcert on macOS",
9090
+ command: "brew install mkcert nss",
9091
+ manager: "brew"
9092
+ });
9093
+ } else {
9094
+ steps.push({
9095
+ id: "mkcert-install-linux",
9096
+ title: "Install mkcert on Linux",
9097
+ command: "sudo apt-get update && sudo apt-get install -y mkcert libnss3-tools",
9098
+ manager: "apt",
9099
+ privileged: true
9100
+ });
9171
9101
  }
9102
+ steps.push({
9103
+ id: "mkcert-local-ca",
9104
+ title: "Install local mkcert CA",
9105
+ command: "mkcert -install",
9106
+ manager: "custom"
9107
+ }, {
9108
+ id: "mkcert-issue",
9109
+ title: `Issue certificate for ${domains.join(", ")}`,
9110
+ command: `mkdir -p ${quote3(certDir())} && mkcert -cert-file ${quote3(certPath)} -key-file ${quote3(keyPath)} ${domains.map((domain) => quote3(domain)).join(" ")}`,
9111
+ manager: "custom"
9112
+ });
9172
9113
  return {
9173
- machineId: resolved.machine_id ?? machineId,
9174
- target: resolved.target,
9175
- route: resolved.route,
9176
- confidence: resolved.confidence,
9177
- warnings: resolved.warnings
9114
+ machineId: process.env["HASNA_MACHINES_MACHINE_ID"] || "local",
9115
+ mode: "plan",
9116
+ steps,
9117
+ executed: 0
9178
9118
  };
9179
9119
  }
9180
- function buildSshCommand(machineId, remoteCommand, options = {}) {
9181
- const resolved = resolveSshTarget(machineId, options);
9182
- return remoteCommand ? `ssh ${resolved.target} ${shellQuote2(remoteCommand)}` : `ssh ${resolved.target}`;
9120
+ function runCertPlan(domains, options = {}) {
9121
+ const plan = buildCertPlan(domains);
9122
+ if (!options.apply)
9123
+ return plan;
9124
+ if (!options.yes) {
9125
+ throw new Error("Certificate generation requires --yes.");
9126
+ }
9127
+ let executed = 0;
9128
+ for (const step of plan.steps) {
9129
+ const result = Bun.spawnSync(["bash", "-lc", step.command], {
9130
+ stdout: "pipe",
9131
+ stderr: "pipe",
9132
+ env: process.env
9133
+ });
9134
+ if (result.exitCode !== 0) {
9135
+ throw new Error(`Certificate step failed (${step.id}): ${result.stderr.toString().trim()}`);
9136
+ }
9137
+ executed += 1;
9138
+ }
9139
+ return {
9140
+ machineId: plan.machineId,
9141
+ mode: "apply",
9142
+ steps: plan.steps,
9143
+ executed
9144
+ };
9183
9145
  }
9184
9146
 
9185
- // src/remote.ts
9186
- function shellQuote3(value) {
9187
- return `'${value.replace(/'/g, "'\\''")}'`;
9147
+ // src/commands/dns.ts
9148
+ init_paths();
9149
+ import { existsSync as existsSync6, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
9150
+ import { join as join6 } from "path";
9151
+ function getDnsPath() {
9152
+ return join6(getDataDir(), "dns.json");
9188
9153
  }
9189
- function machineIsLocal(machineId, localMachineId) {
9190
- return machineId === "local" || machineId === "localhost" || machineId === localMachineId || machineId === hostname5();
9154
+ function readMappings() {
9155
+ const path = getDnsPath();
9156
+ if (!existsSync6(path))
9157
+ return [];
9158
+ return JSON.parse(readFileSync3(path, "utf8"));
9191
9159
  }
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;
9160
+ function writeMappings(mappings) {
9161
+ const path = getDnsPath();
9162
+ ensureParentDir(path);
9163
+ writeFileSync2(path, `${JSON.stringify(mappings, null, 2)}
9164
+ `, "utf8");
9165
+ return path;
9166
+ }
9167
+ function addDomainMapping(domain, port, targetHost = "127.0.0.1") {
9168
+ const mappings = readMappings().filter((entry) => entry.domain !== domain);
9169
+ mappings.push({ domain, port, targetHost });
9170
+ writeMappings(mappings);
9171
+ return mappings.sort((left, right) => left.domain.localeCompare(right.domain));
9172
+ }
9173
+ function listDomainMappings() {
9174
+ return readMappings().sort((left, right) => left.domain.localeCompare(right.domain));
9175
+ }
9176
+ function renderDomainMapping(domain) {
9177
+ const entry = readMappings().find((mapping) => mapping.domain === domain);
9178
+ if (!entry) {
9179
+ throw new Error(`Domain mapping not found: ${domain}`);
9207
9180
  }
9181
+ return {
9182
+ hostsEntry: `${entry.targetHost} ${entry.domain}`,
9183
+ caddySnippet: `${entry.domain} {
9184
+ reverse_proxy 127.0.0.1:${entry.port}
9185
+ tls ${join6(getDataDir(), "certs", `${entry.domain}.pem`)} ${join6(getDataDir(), "certs", `${entry.domain}-key.pem`)}
9186
+ }`,
9187
+ certPath: join6(getDataDir(), "certs", `${entry.domain}.pem`),
9188
+ keyPath: join6(getDataDir(), "certs", `${entry.domain}-key.pem`)
9189
+ };
9208
9190
  }
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
- });
9191
+
9192
+ // src/commands/diff.ts
9193
+ function packageNames(machine) {
9194
+ return (machine.packages || []).map((pkg) => pkg.name).sort();
9195
+ }
9196
+ function fileTargets(machine) {
9197
+ return (machine.files || []).map((file) => `${file.source}->${file.target}`).sort();
9198
+ }
9199
+ function diffMachines(leftMachineId, rightMachineId) {
9200
+ const left = getManifestMachine(leftMachineId);
9201
+ if (!left) {
9202
+ throw new Error(`Machine not found in manifest: ${leftMachineId}`);
9203
+ }
9204
+ const right = rightMachineId ? getManifestMachine(rightMachineId) : detectCurrentMachineManifest();
9205
+ if (!right) {
9206
+ throw new Error(`Machine not found in manifest: ${rightMachineId}`);
9207
+ }
9208
+ const changedFields = [
9209
+ left.platform !== right.platform ? "platform" : null,
9210
+ left.connection !== right.connection ? "connection" : null,
9211
+ left.workspacePath !== right.workspacePath ? "workspacePath" : null,
9212
+ left.bunPath !== right.bunPath ? "bunPath" : null
9213
+ ].filter(Boolean);
9214
+ const leftPackages = new Set(packageNames(left));
9215
+ const rightPackages = new Set(packageNames(right));
9216
+ const leftFiles = new Set(fileTargets(left));
9217
+ const rightFiles = new Set(fileTargets(right));
9215
9218
  return {
9216
- machineId,
9217
- source: resolved.source,
9218
- stdout: result.stdout || "",
9219
- stderr: result.stderr || "",
9220
- exitCode: result.status ?? 1
9219
+ leftMachineId: left.id,
9220
+ rightMachineId: right.id,
9221
+ changedFields,
9222
+ missingPackages: {
9223
+ leftOnly: [...leftPackages].filter((pkg) => !rightPackages.has(pkg)),
9224
+ rightOnly: [...rightPackages].filter((pkg) => !leftPackages.has(pkg))
9225
+ },
9226
+ missingFiles: {
9227
+ leftOnly: [...leftFiles].filter((file) => !rightFiles.has(file)),
9228
+ rightOnly: [...rightFiles].filter((file) => !leftFiles.has(file))
9229
+ }
9221
9230
  };
9222
9231
  }
9223
9232
 
@@ -9282,7 +9291,14 @@ function buildAppSteps(machine) {
9282
9291
  }));
9283
9292
  }
9284
9293
  function resolveMachine(machineId) {
9285
- return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
9294
+ if (!machineId)
9295
+ return detectCurrentMachineManifest();
9296
+ return getManifestMachine(machineId) || {
9297
+ id: machineId,
9298
+ platform: "linux",
9299
+ workspacePath: "",
9300
+ apps: []
9301
+ };
9286
9302
  }
9287
9303
  function parseProbeOutput(app, machine, stdout) {
9288
9304
  const lines = stdout.trim().split(`
@@ -9313,27 +9329,28 @@ function buildAppsPlan(machineId) {
9313
9329
  executed: 0
9314
9330
  };
9315
9331
  }
9316
- function getAppsStatus(machineId) {
9332
+ function getAppsStatus(machineId, runner = runMachineCommand) {
9317
9333
  const machine = resolveMachine(machineId);
9334
+ const readiness = requireMachineCommandSuccess("Apps status readiness check", runner(machine.id, "true"));
9318
9335
  const apps = (machine.apps || []).map((app) => {
9319
- const probe = runMachineCommand(machine.id, buildAppProbeCommand(machine, app));
9336
+ const probe = requireMachineCommandSuccess(`App probe ${app.name}`, runner(machine.id, buildAppProbeCommand(machine, app)));
9320
9337
  return parseProbeOutput(app, machine, probe.stdout);
9321
9338
  });
9322
9339
  return {
9323
9340
  machineId: machine.id,
9324
- source: apps.length > 0 ? runMachineCommand(machine.id, "true").source : machine.id === detectCurrentMachineManifest().id ? "local" : runMachineCommand(machine.id, "true").source,
9341
+ source: readiness.source,
9325
9342
  apps
9326
9343
  };
9327
9344
  }
9328
- function diffApps(machineId) {
9329
- const status = getAppsStatus(machineId);
9345
+ function diffApps(machineId, runner = runMachineCommand) {
9346
+ const status = getAppsStatus(machineId, runner);
9330
9347
  return {
9331
9348
  ...status,
9332
9349
  missing: status.apps.filter((app) => !app.installed).map((app) => app.name),
9333
9350
  installed: status.apps.filter((app) => app.installed).map((app) => app.name)
9334
9351
  };
9335
9352
  }
9336
- function runAppsInstall(machineId, options = {}) {
9353
+ function runAppsInstall(machineId, options = {}, runner = runMachineCommand) {
9337
9354
  const plan = buildAppsPlan(machineId);
9338
9355
  if (!options.apply)
9339
9356
  return plan;
@@ -9342,14 +9359,7 @@ function runAppsInstall(machineId, options = {}) {
9342
9359
  }
9343
9360
  let executed = 0;
9344
9361
  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
- }
9362
+ requireMachineCommandSuccess(`App install ${step.id}`, runner(plan.machineId, step.command));
9353
9363
  executed += 1;
9354
9364
  }
9355
9365
  return {
@@ -9393,7 +9403,13 @@ function buildInstallSteps(machine, tools) {
9393
9403
  }));
9394
9404
  }
9395
9405
  function resolveMachine2(machineId) {
9396
- return (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
9406
+ if (!machineId)
9407
+ return detectCurrentMachineManifest();
9408
+ return getManifestMachine(machineId) || {
9409
+ id: machineId,
9410
+ platform: "linux",
9411
+ workspacePath: ""
9412
+ };
9397
9413
  }
9398
9414
  function buildProbeCommand(tool) {
9399
9415
  const binary = getToolBinary(tool);
@@ -9420,25 +9436,28 @@ function buildClaudeInstallPlan(machineId, tools) {
9420
9436
  executed: 0
9421
9437
  };
9422
9438
  }
9423
- function getClaudeCliStatus(machineId, tools) {
9439
+ function getClaudeCliStatus(machineId, tools, runner = runMachineCommand) {
9424
9440
  const machine = resolveMachine2(machineId);
9425
9441
  const normalizedTools = normalizeTools(tools);
9426
- const route = runMachineCommand(machine.id, "true").source;
9442
+ const route = requireMachineCommandSuccess("AI CLI status readiness check", runner(machine.id, "true")).source;
9427
9443
  return {
9428
9444
  machineId: machine.id,
9429
9445
  source: route,
9430
- tools: normalizedTools.map((tool) => parseProbe(tool, runMachineCommand(machine.id, buildProbeCommand(tool)).stdout))
9446
+ tools: normalizedTools.map((tool) => {
9447
+ const result = requireMachineCommandSuccess(`AI CLI probe ${tool}`, runner(machine.id, buildProbeCommand(tool)));
9448
+ return parseProbe(tool, result.stdout);
9449
+ })
9431
9450
  };
9432
9451
  }
9433
- function diffClaudeCli(machineId, tools) {
9434
- const status = getClaudeCliStatus(machineId, tools);
9452
+ function diffClaudeCli(machineId, tools, runner = runMachineCommand) {
9453
+ const status = getClaudeCliStatus(machineId, tools, runner);
9435
9454
  return {
9436
9455
  ...status,
9437
9456
  missing: status.tools.filter((tool) => !tool.installed).map((tool) => tool.tool),
9438
9457
  installed: status.tools.filter((tool) => tool.installed).map((tool) => tool.tool)
9439
9458
  };
9440
9459
  }
9441
- function runClaudeInstall(machineId, tools, options = {}) {
9460
+ function runClaudeInstall(machineId, tools, options = {}, runner = runMachineCommand) {
9442
9461
  const plan = buildClaudeInstallPlan(machineId, tools);
9443
9462
  if (!options.apply)
9444
9463
  return plan;
@@ -9447,14 +9466,7 @@ function runClaudeInstall(machineId, tools, options = {}) {
9447
9466
  }
9448
9467
  let executed = 0;
9449
9468
  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
- }
9469
+ requireMachineCommandSuccess(`AI CLI install ${step.id}`, runner(plan.machineId, step.command));
9458
9470
  executed += 1;
9459
9471
  }
9460
9472
  return {
@@ -9498,7 +9510,10 @@ function buildInstallSteps2(machine) {
9498
9510
  ];
9499
9511
  }
9500
9512
  function buildTailscaleInstallPlan(machineId) {
9501
- const machine = (machineId ? getManifestMachine(machineId) : null) || detectCurrentMachineManifest();
9513
+ const machine = machineId ? getManifestMachine(machineId) : detectCurrentMachineManifest();
9514
+ if (!machine) {
9515
+ throw new Error(`Machine not found in manifest: ${machineId}`);
9516
+ }
9502
9517
  return {
9503
9518
  machineId: machine.id,
9504
9519
  mode: "plan",
@@ -9506,7 +9521,7 @@ function buildTailscaleInstallPlan(machineId) {
9506
9521
  executed: 0
9507
9522
  };
9508
9523
  }
9509
- function runTailscaleInstall(machineId, options = {}) {
9524
+ function runTailscaleInstall(machineId, options = {}, runner = runMachineCommand) {
9510
9525
  const plan = buildTailscaleInstallPlan(machineId);
9511
9526
  if (!options.apply)
9512
9527
  return plan;
@@ -9515,14 +9530,7 @@ function runTailscaleInstall(machineId, options = {}) {
9515
9530
  }
9516
9531
  let executed = 0;
9517
9532
  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
- }
9533
+ requireMachineCommandSuccess(`Tailscale install ${step.id}`, runner(plan.machineId, step.command));
9526
9534
  executed += 1;
9527
9535
  }
9528
9536
  return {
@@ -10563,16 +10571,17 @@ function quote4(value) {
10563
10571
  return `'${value.replace(/'/g, `'\\''`)}'`;
10564
10572
  }
10565
10573
  function packageCheckCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
10574
+ const quotedPackageName = quote4(packageName);
10566
10575
  if (manager === "bun") {
10567
- return `bun pm ls -g --all | grep -F ${quote4(packageName)}`;
10576
+ 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
10577
  }
10569
10578
  if (manager === "brew") {
10570
- return `brew list --versions ${quote4(packageName)}`;
10579
+ return `if brew list --versions ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
10571
10580
  }
10572
10581
  if (manager === "apt") {
10573
- return `dpkg -s ${quote4(packageName)} >/dev/null 2>&1`;
10582
+ return `if dpkg -s ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
10574
10583
  }
10575
- return `command -v ${quote4(packageName)} >/dev/null 2>&1`;
10584
+ return `if command -v ${quotedPackageName} >/dev/null 2>&1; then printf 'installed=1\\n'; else printf 'installed=0\\n'; fi`;
10576
10585
  }
10577
10586
  function packageInstallCommand(machine, packageName, manager = machine.platform === "macos" ? "brew" : "apt") {
10578
10587
  if (manager === "bun") {
@@ -10586,15 +10595,15 @@ function packageInstallCommand(machine, packageName, manager = machine.platform
10586
10595
  }
10587
10596
  return packageName;
10588
10597
  }
10589
- function detectPackageActions(machine) {
10598
+ function detectPackageActions(machine, runner) {
10590
10599
  return (machine.packages || []).map((pkg, index) => {
10591
10600
  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;
10601
+ const check = runner(machine.id, packageCheckCommand(machine, pkg.name, manager));
10602
+ if (check.exitCode !== 0) {
10603
+ throw new Error(describeMachineCommandFailure(`Sync package probe ${pkg.name}`, check));
10604
+ }
10605
+ const installed = check.stdout.split(`
10606
+ `).some((line) => line.trim() === "installed=1");
10598
10607
  return {
10599
10608
  id: `package-${index + 1}`,
10600
10609
  title: `${installed ? "Package present" : "Install package"} ${pkg.name}`,
@@ -10605,6 +10614,9 @@ function detectPackageActions(machine) {
10605
10614
  });
10606
10615
  }
10607
10616
  function detectFileActions(machine) {
10617
+ if ((machine.files || []).length > 0 && resolveMachineCommand(machine.id, "true").source !== "local") {
10618
+ throw new Error(`Remote file sync planning is not supported for ${machine.id}; refusing to inspect or apply local paths as remote state.`);
10619
+ }
10608
10620
  return (machine.files || []).map((file, index) => {
10609
10621
  const sourceExists = existsSync9(file.source);
10610
10622
  const targetExists = existsSync9(file.target);
@@ -10628,17 +10640,20 @@ function detectFileActions(machine) {
10628
10640
  };
10629
10641
  });
10630
10642
  }
10631
- function buildSyncPlan(machineId) {
10643
+ function buildSyncPlan(machineId, runner = runMachineCommand) {
10632
10644
  const manifest = readManifest();
10633
10645
  const currentMachineId = getLocalMachineId();
10634
10646
  const selected = machineId ? manifest.machines.find((machine) => machine.id === machineId) : manifest.machines.find((machine) => machine.id === currentMachineId);
10647
+ if (machineId && !selected) {
10648
+ throw new Error(`Machine not found in manifest: ${machineId}`);
10649
+ }
10635
10650
  const target = selected || {
10636
10651
  id: currentMachineId,
10637
10652
  platform: "linux",
10638
10653
  workspacePath: `${homedir7()}/workspace`
10639
10654
  };
10640
10655
  const actions = [
10641
- ...detectPackageActions(target),
10656
+ ...detectPackageActions(target, runner),
10642
10657
  ...detectFileActions(target)
10643
10658
  ];
10644
10659
  return {
@@ -10668,8 +10683,8 @@ function applyFileAction(command) {
10668
10683
  symlinkSync(sourcePath, targetPath);
10669
10684
  }
10670
10685
  }
10671
- function runSync(machineId, options = {}) {
10672
- const plan = buildSyncPlan(machineId);
10686
+ function runSync(machineId, options = {}, runner = runMachineCommand) {
10687
+ const plan = buildSyncPlan(machineId, runner);
10673
10688
  if (!options.apply) {
10674
10689
  return plan;
10675
10690
  }
@@ -10683,18 +10698,17 @@ function runSync(machineId, options = {}) {
10683
10698
  if (action.kind === "file") {
10684
10699
  applyFileAction(action.command);
10685
10700
  } else {
10686
- const result = Bun.spawnSync(["bash", "-lc", action.command], {
10687
- stdout: "pipe",
10688
- stderr: "pipe",
10689
- env: process.env
10690
- });
10701
+ const result = runner(plan.machineId, action.command);
10691
10702
  if (result.exitCode !== 0) {
10692
10703
  recordSyncRun(plan.machineId, "failed", {
10693
10704
  executed,
10694
10705
  failedAction: action,
10695
- stderr: result.stderr.toString()
10706
+ stderr: result.stderr,
10707
+ stdout: result.stdout,
10708
+ exitCode: result.exitCode,
10709
+ source: result.source
10696
10710
  });
10697
- throw new Error(`Sync action failed (${action.id}): ${result.stderr.toString().trim()}`);
10711
+ throw new Error(describeMachineCommandFailure(`Sync action ${action.id}`, result));
10698
10712
  }
10699
10713
  }
10700
10714
  executed += 1;