@hermespilot/link 0.5.2 → 0.5.4

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.
@@ -4425,7 +4425,7 @@ async function listCronOutputFiles(profileName, jobId) {
4425
4425
  mtimeMs: fileStat.mtimeMs
4426
4426
  });
4427
4427
  }
4428
- return files.sort((left, right) => left.mtimeMs - right.mtimeMs).map(({ path: path26, mtime }) => ({ path: path26, mtime }));
4428
+ return files.sort((left, right) => left.mtimeMs - right.mtimeMs).map(({ path: path27, mtime }) => ({ path: path27, mtime }));
4429
4429
  }
4430
4430
  async function readCronOutput(outputPath) {
4431
4431
  const content = await readFile3(outputPath, "utf8");
@@ -4502,7 +4502,7 @@ import os2 from "os";
4502
4502
  import path5 from "path";
4503
4503
 
4504
4504
  // src/constants.ts
4505
- var LINK_VERSION = "0.5.2";
4505
+ var LINK_VERSION = "0.5.4";
4506
4506
  var LINK_COMMAND = "hermeslink";
4507
4507
  var LINK_DEFAULT_PORT = 52379;
4508
4508
  var LINK_RUNTIME_DIR_NAME = ".hermeslink";
@@ -12995,10 +12995,10 @@ function parseHermesApiCapabilities(payload) {
12995
12995
  sessionKeyHeader: readString10(features, "session_key_header")
12996
12996
  };
12997
12997
  }
12998
- async function callHermesApi(path26, init, options) {
12998
+ async function callHermesApi(path27, init, options) {
12999
12999
  const method = init.method ?? "GET";
13000
13000
  const startedAt = Date.now();
13001
- void options.logger?.debug("hermes_api_request_started", { method, path: path26 });
13001
+ void options.logger?.debug("hermes_api_request_started", { method, path: path27 });
13002
13002
  const availability = await ensureHermesApiServerAvailable({
13003
13003
  fetchImpl: options.fetchImpl,
13004
13004
  logger: options.logger,
@@ -13006,7 +13006,7 @@ async function callHermesApi(path26, init, options) {
13006
13006
  });
13007
13007
  let config = availability.configResult.apiServer;
13008
13008
  const fetcher = options.fetchImpl ?? fetch;
13009
- const request = () => fetchHermesApi(fetcher, config, path26, init, options);
13009
+ const request = () => fetchHermesApi(fetcher, config, path27, init, options);
13010
13010
  let response;
13011
13011
  try {
13012
13012
  response = await request();
@@ -13014,7 +13014,7 @@ async function callHermesApi(path26, init, options) {
13014
13014
  logHermesApiError(
13015
13015
  options.logger,
13016
13016
  method,
13017
- path26,
13017
+ path27,
13018
13018
  options.profileName,
13019
13019
  startedAt,
13020
13020
  error
@@ -13025,7 +13025,7 @@ async function callHermesApi(path26, init, options) {
13025
13025
  logHermesApiResponse(
13026
13026
  options.logger,
13027
13027
  method,
13028
- path26,
13028
+ path27,
13029
13029
  options.profileName,
13030
13030
  startedAt,
13031
13031
  response
@@ -13034,7 +13034,7 @@ async function callHermesApi(path26, init, options) {
13034
13034
  }
13035
13035
  void options.logger?.warn("hermes_api_request_retrying_after_401", {
13036
13036
  method,
13037
- path: path26,
13037
+ path: path27,
13038
13038
  profile: options.profileName ?? "default",
13039
13039
  port: config.port ?? null,
13040
13040
  duration_ms: Date.now() - startedAt
@@ -13052,7 +13052,7 @@ async function callHermesApi(path26, init, options) {
13052
13052
  logHermesApiError(
13053
13053
  options.logger,
13054
13054
  method,
13055
- path26,
13055
+ path27,
13056
13056
  options.profileName,
13057
13057
  startedAt,
13058
13058
  error
@@ -13062,7 +13062,7 @@ async function callHermesApi(path26, init, options) {
13062
13062
  logHermesApiResponse(
13063
13063
  options.logger,
13064
13064
  method,
13065
- path26,
13065
+ path27,
13066
13066
  options.profileName,
13067
13067
  startedAt,
13068
13068
  response
@@ -13072,7 +13072,7 @@ async function callHermesApi(path26, init, options) {
13072
13072
  }
13073
13073
  void options.logger?.warn("hermes_api_request_repairing_after_401", {
13074
13074
  method,
13075
- path: path26,
13075
+ path: path27,
13076
13076
  profile: options.profileName ?? "default",
13077
13077
  port: config.port ?? null,
13078
13078
  duration_ms: Date.now() - startedAt
@@ -13092,7 +13092,7 @@ async function callHermesApi(path26, init, options) {
13092
13092
  logHermesApiError(
13093
13093
  options.logger,
13094
13094
  method,
13095
- path26,
13095
+ path27,
13096
13096
  options.profileName,
13097
13097
  startedAt,
13098
13098
  error
@@ -13102,21 +13102,21 @@ async function callHermesApi(path26, init, options) {
13102
13102
  logHermesApiResponse(
13103
13103
  options.logger,
13104
13104
  method,
13105
- path26,
13105
+ path27,
13106
13106
  options.profileName,
13107
13107
  startedAt,
13108
13108
  response
13109
13109
  );
13110
13110
  return response;
13111
13111
  }
13112
- async function fetchHermesApi(fetcher, config, path26, init, options) {
13112
+ async function fetchHermesApi(fetcher, config, path27, init, options) {
13113
13113
  const headers = new Headers(init.headers);
13114
13114
  headers.set("accept", headers.get("accept") ?? "application/json");
13115
13115
  if (config.key) {
13116
13116
  headers.set("x-api-key", config.key);
13117
13117
  headers.set("authorization", `Bearer ${config.key}`);
13118
13118
  }
13119
- return await fetcher(`http://127.0.0.1:${config.port}${path26}`, {
13119
+ return await fetcher(`http://127.0.0.1:${config.port}${path27}`, {
13120
13120
  ...init,
13121
13121
  headers
13122
13122
  }).catch((error) => {
@@ -13125,10 +13125,10 @@ async function fetchHermesApi(fetcher, config, path26, init, options) {
13125
13125
  }
13126
13126
  void options.logger?.warn("hermes_api_server_connect_failed", {
13127
13127
  method: String(init.method ?? "GET").toUpperCase(),
13128
- path: path26,
13128
+ path: path27,
13129
13129
  profile: options.profileName ?? "default",
13130
13130
  port: config.port ?? null,
13131
- url: `http://127.0.0.1:${config.port}${path26}`,
13131
+ url: `http://127.0.0.1:${config.port}${path27}`,
13132
13132
  error: error instanceof Error ? error.message : String(error)
13133
13133
  });
13134
13134
  throw new LinkHttpError(
@@ -13138,10 +13138,10 @@ async function fetchHermesApi(fetcher, config, path26, init, options) {
13138
13138
  );
13139
13139
  });
13140
13140
  }
13141
- function logHermesApiResponse(logger, method, path26, profileName, startedAt, response) {
13141
+ function logHermesApiResponse(logger, method, path27, profileName, startedAt, response) {
13142
13142
  const fields = {
13143
13143
  method,
13144
- path: path26,
13144
+ path: path27,
13145
13145
  profile: profileName ?? "default",
13146
13146
  status: response.status,
13147
13147
  duration_ms: Date.now() - startedAt
@@ -13162,10 +13162,10 @@ async function logHermesApiFailureResponse(logger, fields, response) {
13162
13162
  ...upstreamError ? { upstream_error: upstreamError } : {}
13163
13163
  });
13164
13164
  }
13165
- function logHermesApiError(logger, method, path26, profileName, startedAt, error) {
13165
+ function logHermesApiError(logger, method, path27, profileName, startedAt, error) {
13166
13166
  void logger?.warn("hermes_api_request_failed", {
13167
13167
  method,
13168
- path: path26,
13168
+ path: path27,
13169
13169
  profile: profileName ?? "default",
13170
13170
  duration_ms: Date.now() - startedAt,
13171
13171
  ...error instanceof LinkHttpError ? { status: error.status, code: error.code } : {},
@@ -22949,929 +22949,2431 @@ function readString17(payload, key) {
22949
22949
  }
22950
22950
 
22951
22951
  // src/link/updates.ts
22952
- import { spawn as spawn4 } from "child_process";
22952
+ import { spawn as spawn5 } from "child_process";
22953
22953
  import { EventEmitter as EventEmitter4 } from "events";
22954
- import { mkdir as mkdir13, readFile as readFile17, rm as rm8 } from "fs/promises";
22954
+ import { mkdir as mkdir15, readFile as readFile18, rm as rm10 } from "fs/promises";
22955
+ import path25 from "path";
22956
+
22957
+ // src/daemon/process.ts
22958
+ import { spawn as spawn4 } from "child_process";
22959
+ import { mkdir as mkdir14, readFile as readFile17, rm as rm9 } from "fs/promises";
22955
22960
  import path24 from "path";
22956
- var SERVER_LINK_CURRENT_RELEASE_PATH = "/api/v1/link/releases/current";
22957
- var LINK_NPM_PACKAGE = "@hermespilot/link";
22958
- var OFFICIAL_INSTALLER_BASE_URL = "https://raw.githubusercontent.com/HangbinYang/hermespilot-install/main";
22959
- var OFFICIAL_UNIX_INSTALLER_URL = `${OFFICIAL_INSTALLER_BASE_URL}/install.sh`;
22960
- var OFFICIAL_WINDOWS_INSTALLER_URL = `${OFFICIAL_INSTALLER_BASE_URL}/install.ps1`;
22961
- var UPDATE_LOG_FILE2 = "link-update.log";
22962
- var UPDATE_LOG_MAX_FILES2 = 3;
22963
- var UPDATE_FETCH_TIMEOUT_MS = 5e3;
22964
- var MAX_UPDATE_LOG_LINES2 = 240;
22965
- var MAX_OUTPUT_LINE_LENGTH3 = 1200;
22966
- var updateEvents2 = new EventEmitter4();
22967
- var runningUpdate2 = null;
22968
- async function readLinkUpdateCheck(options) {
22969
- const remoteResult = await readRemoteLinkPolicy(options);
22970
- const remote = remoteResult.remote;
22971
- const state = computeLinkUpdateState(LINK_VERSION, remote);
22972
- const targetVersion = remote?.target_version ?? null;
22973
- return {
22974
- ok: true,
22975
- local: {
22976
- version: LINK_VERSION,
22977
- raw: LINK_VERSION
22978
- },
22979
- remote,
22980
- state,
22981
- update_available: state === "update_available" || state === "unsafe" || state === "blocked",
22982
- unsafe: state === "unsafe",
22983
- blocked: state === "blocked",
22984
- check_state: remoteResult.state,
22985
- issue: remoteResult.issue,
22986
- manual: {
22987
- command: targetVersion ? manualInstallCommand(targetVersion) : null,
22988
- package: LINK_NPM_PACKAGE,
22989
- version: targetVersion
22990
- }
22991
- };
22992
- }
22993
- async function startLinkUpdate(options) {
22994
- const current = await readLinkUpdateStatus(options.paths);
22995
- if (runningUpdate2 || current.state === "running") {
22996
- return current;
22997
- }
22998
- const check = await readLinkUpdateCheck(options);
22999
- const targetVersion = check.remote?.target_version ?? null;
23000
- if (!targetVersion) {
23001
- return writeFailedStartState(
23002
- options,
23003
- "HermesPilot Server has no Link target version."
23004
- );
22961
+
22962
+ // src/daemon/service.ts
22963
+ import { createServer } from "http";
22964
+ import { mkdir as mkdir13, rm as rm8, writeFile as writeFile3 } from "fs/promises";
22965
+
22966
+ // src/relay/control-client.ts
22967
+ import WebSocket from "ws";
22968
+
22969
+ // src/relay/stream-policy.ts
22970
+ var DEFAULT_RELAY_STREAM_BATCH_POLICY = {
22971
+ flushIntervalMs: 50,
22972
+ flushBytes: 2 * 1024
22973
+ };
22974
+ var RELAY_STREAM_POLICY_CONSTRAINTS = {
22975
+ flushIntervalMs: {
22976
+ min: 50,
22977
+ max: 1e3
22978
+ },
22979
+ flushBytes: {
22980
+ min: 1024,
22981
+ max: 64 * 1024
23005
22982
  }
23006
- if (!isValidReleaseVersion(targetVersion)) {
23007
- return writeFailedStartState(
23008
- options,
23009
- `HermesPilot Server returned invalid Link target version: ${targetVersion}.`,
23010
- targetVersion
23011
- );
22983
+ };
22984
+ async function fetchRelayStreamBatchPolicy(serverBaseUrl, options = {}) {
22985
+ const fetchImpl = options.fetchImpl ?? fetch;
22986
+ const controller = new AbortController();
22987
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e3);
22988
+ timeout.unref?.();
22989
+ try {
22990
+ const response = await fetchImpl(`${serverBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/stream-policy`, {
22991
+ headers: {
22992
+ accept: "application/json"
22993
+ },
22994
+ signal: controller.signal
22995
+ });
22996
+ if (!response.ok) {
22997
+ return null;
22998
+ }
22999
+ const payload = await response.json().catch(() => null);
23000
+ return readRelayStreamBatchPolicy(payload);
23001
+ } catch {
23002
+ return null;
23003
+ } finally {
23004
+ clearTimeout(timeout);
23012
23005
  }
23013
- if (options.targetVersion && options.targetVersion !== targetVersion) {
23014
- return writeFailedStartState(
23015
- options,
23016
- `Requested target ${options.targetVersion} does not match current Link target ${targetVersion}.`,
23017
- targetVersion
23018
- );
23006
+ }
23007
+ function readRelayStreamBatchPolicy(input) {
23008
+ const record = readRecord(input);
23009
+ const body = readRecord(record?.policy) ?? readRecord(record?.stream_batching) ?? record;
23010
+ return normalizeRelayStreamBatchPolicy(body);
23011
+ }
23012
+ function normalizeRelayStreamBatchPolicy(input) {
23013
+ const record = readRecord(input);
23014
+ if (!record) {
23015
+ return null;
23019
23016
  }
23020
- if (check.state === "current") {
23021
- return writeFailedStartState(
23022
- options,
23023
- "Hermes Link is already on the current version.",
23024
- targetVersion
23025
- );
23017
+ const flushIntervalMs = readInteger4(record.flushIntervalMs ?? record.flush_interval_ms);
23018
+ const flushBytes = readInteger4(record.flushBytes ?? record.flush_bytes);
23019
+ if (flushIntervalMs === null || flushBytes === null || flushIntervalMs < RELAY_STREAM_POLICY_CONSTRAINTS.flushIntervalMs.min || flushIntervalMs > RELAY_STREAM_POLICY_CONSTRAINTS.flushIntervalMs.max || flushBytes < RELAY_STREAM_POLICY_CONSTRAINTS.flushBytes.min || flushBytes > RELAY_STREAM_POLICY_CONSTRAINTS.flushBytes.max) {
23020
+ return null;
23026
23021
  }
23027
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
23028
- const jobId = `link_update_${now().getTime().toString(36)}`;
23029
- await clearUpdateLogFiles2(options.paths);
23030
- const writer = createRotatingTextLogWriter({
23031
- paths: options.paths,
23032
- fileName: UPDATE_LOG_FILE2,
23033
- maxFileBytes: 512 * 1024,
23034
- maxFiles: UPDATE_LOG_MAX_FILES2
23035
- });
23036
- const startedAt = now().toISOString();
23037
- const installCommand = buildOfficialInstallCommand(targetVersion);
23038
- const manualCommand = installCommand.displayCommand;
23039
- const started = {
23040
- state: "running",
23041
- job_id: jobId,
23042
- pid: null,
23043
- target_version: targetVersion,
23044
- started_at: startedAt,
23045
- finished_at: null,
23046
- exit_code: null,
23047
- signal: null,
23048
- error: null,
23049
- manual_command: manualCommand
23022
+ return {
23023
+ flushIntervalMs,
23024
+ flushBytes
23050
23025
  };
23051
- await mkdir13(options.paths.runDir, { recursive: true, mode: 448 });
23052
- await writer.write(
23053
- `
23054
- === link update started ${startedAt} target=${targetVersion} ===
23055
- `
23056
- );
23057
- await writer.write(`$ ${manualCommand}
23058
- `);
23059
- await writeUpdateState2(options.paths, started);
23060
- const child = spawnInstallCommand(installCommand);
23061
- started.pid = child.pid ?? null;
23062
- await writeUpdateState2(options.paths, started);
23063
- const appendChunk = async (chunk) => {
23064
- await writer.write(chunk);
23065
- await emitUpdateStatus2(options.paths);
23026
+ }
23027
+ function readRecord(value) {
23028
+ return value && typeof value === "object" ? value : null;
23029
+ }
23030
+ function readInteger4(value) {
23031
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
23032
+ }
23033
+
23034
+ // src/relay/control-client.ts
23035
+ function connectRelayControl(options) {
23036
+ const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
23037
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
23038
+ wsUrl.searchParams.set("link_id", options.linkId);
23039
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
23040
+ const backoffBaseMs = options.backoffBaseMs ?? 1e3;
23041
+ const backoffMaxMs = options.backoffMaxMs ?? 3e4;
23042
+ let reconnectAttempts = 0;
23043
+ let closedByUser = false;
23044
+ let socket = null;
23045
+ let retryTimer = null;
23046
+ let abortControllers = /* @__PURE__ */ new Map();
23047
+ let fatalRelayRejection = null;
23048
+ let latestNetworkRoutes = null;
23049
+ const streamBatchPolicy = {
23050
+ current: options.initialStreamBatchPolicy ?? DEFAULT_RELAY_STREAM_BATCH_POLICY,
23051
+ onUpdate: options.onStreamBatchPolicy
23066
23052
  };
23067
- child.stdout?.on("data", (chunk) => {
23068
- void appendChunk(chunk);
23069
- });
23070
- child.stderr?.on("data", (chunk) => {
23071
- void appendChunk(chunk);
23072
- });
23073
- runningUpdate2 = new Promise((resolve) => {
23074
- child.on("error", (error) => {
23075
- void (async () => {
23076
- const failed = {
23077
- ...started,
23053
+ const connect = () => {
23054
+ options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
23055
+ fatalRelayRejection = null;
23056
+ socket = new WebSocket(wsUrl, {
23057
+ headers: {
23058
+ "x-hermes-link-version": LINK_VERSION
23059
+ }
23060
+ });
23061
+ socket.on("open", () => {
23062
+ reconnectAttempts = 0;
23063
+ options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
23064
+ const currentSocket = socket;
23065
+ if (currentSocket && latestNetworkRoutes) {
23066
+ sendNetworkRoutes(currentSocket, options.linkId, latestNetworkRoutes);
23067
+ }
23068
+ });
23069
+ socket.on("message", (raw) => {
23070
+ if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
23071
+ return;
23072
+ }
23073
+ void handleFrame(socket, String(raw), options.localPort, abortControllers, streamBatchPolicy).catch((error) => {
23074
+ const message = error instanceof Error ? error.message : "Relay request failed";
23075
+ socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
23076
+ });
23077
+ });
23078
+ socket.on("error", (error) => {
23079
+ const message = error instanceof Error ? error.message : "Relay websocket error";
23080
+ fatalRelayRejection = resolveFatalRelayRejection(message);
23081
+ options.onStatus?.({
23082
+ state: "disconnected",
23083
+ attempt: reconnectAttempts,
23084
+ message: fatalRelayRejection ?? message
23085
+ });
23086
+ });
23087
+ socket.on("close", () => {
23088
+ abortAll(abortControllers);
23089
+ abortControllers = /* @__PURE__ */ new Map();
23090
+ if (fatalRelayRejection) {
23091
+ options.onStatus?.({
23078
23092
  state: "failed",
23079
- finished_at: now().toISOString(),
23080
- error: error.message
23081
- };
23082
- await writer.write(
23083
- `
23084
- [failed] link update failed to start: ${error.message}
23085
- `
23086
- );
23087
- await writeUpdateState2(options.paths, failed);
23088
- await emitUpdateStatus2(options.paths);
23089
- void options.logger?.error("link_update_spawn_failed", {
23090
- job_id: jobId,
23091
- target_version: targetVersion,
23092
- error: error.message
23093
+ attempt: reconnectAttempts,
23094
+ message: fatalRelayRejection
23093
23095
  });
23094
- resolve(await readLinkUpdateStatus(options.paths));
23095
- })();
23096
- });
23097
- child.on("close", (code, signal) => {
23098
- void (async () => {
23099
- const succeeded = code === 0;
23100
- const state = {
23101
- ...started,
23102
- state: succeeded ? "restart_required" : "failed",
23103
- finished_at: now().toISOString(),
23104
- exit_code: code,
23105
- signal,
23106
- error: succeeded ? null : `install script exited with code ${code ?? "unknown"}`
23107
- };
23108
- await writer.write(
23109
- `
23110
- === link update finished ${state.finished_at} exit=${code ?? "null"} signal=${signal ?? "null"} ===
23111
- `
23112
- );
23113
- if (succeeded) {
23114
- await writer.write(
23115
- `
23116
- [restart-requested] The install script should restart Hermes Link and verify the running version. If it does not reconnect, run \`${LINK_COMMAND} restart\` on this computer.
23117
- `
23118
- );
23119
- }
23120
- if (succeeded) {
23121
- await writer.flush();
23122
- setTimeout(() => {
23123
- void (async () => {
23124
- await writeUpdateState2(options.paths, state);
23125
- await emitUpdateStatus2(options.paths);
23126
- })();
23127
- }, 1e3).unref();
23128
- } else {
23129
- await writeUpdateState2(options.paths, state);
23130
- await emitUpdateStatus2(options.paths);
23131
- }
23132
- void options.logger?.info(
23133
- succeeded ? "link_update_restart_required" : "link_update_failed",
23134
- {
23135
- job_id: jobId,
23136
- target_version: targetVersion,
23137
- exit_code: code,
23138
- signal: signal ?? null
23139
- }
23140
- );
23141
- resolve(await readLinkUpdateStatus(options.paths));
23142
- })();
23096
+ return;
23097
+ }
23098
+ if (closedByUser) {
23099
+ options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
23100
+ return;
23101
+ }
23102
+ if (reconnectAttempts >= maxReconnectAttempts) {
23103
+ options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
23104
+ return;
23105
+ }
23106
+ reconnectAttempts += 1;
23107
+ const delay3 = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
23108
+ options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay3}ms` });
23109
+ retryTimer = setTimeout(connect, delay3);
23110
+ retryTimer.unref?.();
23143
23111
  });
23144
- }).finally(() => {
23145
- runningUpdate2 = null;
23146
- });
23147
- await emitUpdateStatus2(options.paths);
23148
- void options.logger?.info("link_update_started", {
23149
- job_id: jobId,
23150
- pid: child.pid ?? null,
23151
- target_version: targetVersion,
23152
- log_path: writer.filePath
23153
- });
23154
- return readLinkUpdateStatus(options.paths);
23112
+ };
23113
+ connect();
23114
+ return {
23115
+ publishNetworkRoutes(routes) {
23116
+ latestNetworkRoutes = routes;
23117
+ if (socket?.readyState === WebSocket.OPEN) {
23118
+ sendNetworkRoutes(socket, options.linkId, routes);
23119
+ }
23120
+ },
23121
+ updateStreamBatchPolicy(policy) {
23122
+ streamBatchPolicy.current = policy;
23123
+ streamBatchPolicy.onUpdate?.(policy);
23124
+ },
23125
+ close() {
23126
+ closedByUser = true;
23127
+ if (retryTimer) {
23128
+ clearTimeout(retryTimer);
23129
+ retryTimer = null;
23130
+ }
23131
+ abortAll(abortControllers);
23132
+ socket?.terminate();
23133
+ }
23134
+ };
23155
23135
  }
23156
- async function readLinkUpdateStatus(paths) {
23157
- let state = await readJsonFile(updateStatePath2(paths));
23158
- if (state?.state === "restart_required" && state.target_version) {
23159
- if (compareSemver3(LINK_VERSION, state.target_version) >= 0) {
23160
- state = {
23161
- ...state,
23162
- state: "succeeded",
23163
- finished_at: state.finished_at ?? (/* @__PURE__ */ new Date()).toISOString()
23164
- };
23165
- await writeUpdateState2(paths, state);
23136
+ function sendNetworkRoutes(socket, linkId, routes) {
23137
+ socket.send(JSON.stringify({
23138
+ type: "network.routes",
23139
+ id: `routes_${Date.now().toString(36)}`,
23140
+ payload: {
23141
+ link_id: linkId,
23142
+ lan_ips: routes.lanIps,
23143
+ public_ipv4s: routes.publicIpv4s,
23144
+ public_ipv6s: routes.publicIpv6s,
23145
+ observed_at: (/* @__PURE__ */ new Date()).toISOString()
23166
23146
  }
23147
+ }));
23148
+ }
23149
+ function resolveFatalRelayRejection(message) {
23150
+ if (!/Unexpected server response:\s*(400|401|403|426)\b/u.test(message)) {
23151
+ return null;
23167
23152
  }
23168
- if (state?.state === "running" && !runningUpdate2 && !isRecentRunningState3(state) && !isProcessAlive3(state.pid)) {
23169
- const reachedTarget = state.target_version && compareSemver3(LINK_VERSION, state.target_version) >= 0;
23170
- state = reachedTarget ? {
23171
- ...state,
23172
- state: "succeeded",
23173
- finished_at: (/* @__PURE__ */ new Date()).toISOString(),
23174
- error: null
23175
- } : {
23176
- ...state,
23177
- state: "failed",
23178
- finished_at: (/* @__PURE__ */ new Date()).toISOString(),
23179
- error: state.error ?? "Link update was interrupted before Hermes Link could observe completion."
23180
- };
23181
- await writeUpdateState2(paths, state);
23153
+ return "Relay refused the Hermes Link connection. Check Link version and pairing state before retrying.";
23154
+ }
23155
+ function abortAll(abortControllers) {
23156
+ for (const controller of abortControllers.values()) {
23157
+ controller.abort();
23182
23158
  }
23183
- return {
23184
- ok: true,
23185
- state: state?.state ?? "idle",
23186
- job_id: state?.job_id ?? null,
23187
- pid: state?.pid ?? null,
23188
- target_version: state?.target_version ?? null,
23189
- started_at: state?.started_at ?? null,
23190
- finished_at: state?.finished_at ?? null,
23191
- exit_code: state?.exit_code ?? null,
23192
- signal: state?.signal ?? null,
23193
- log_path: updateLogPath2(paths),
23194
- lines: await readUpdateLogLines2(paths),
23195
- error: state?.error ?? null,
23196
- manual_command: state?.manual_command ?? null
23197
- };
23198
- }
23199
- function subscribeLinkUpdateStatus(listener) {
23200
- updateEvents2.on("status", listener);
23201
- return () => updateEvents2.off("status", listener);
23159
+ abortControllers.clear();
23202
23160
  }
23203
- async function writeFailedStartState(options, error, targetVersion = null) {
23204
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
23205
- const state = {
23206
- state: "failed",
23207
- job_id: `link_update_${now().getTime().toString(36)}`,
23208
- pid: null,
23209
- target_version: targetVersion,
23210
- started_at: now().toISOString(),
23211
- finished_at: now().toISOString(),
23212
- exit_code: null,
23213
- signal: null,
23214
- error,
23215
- manual_command: targetVersion ? manualInstallCommand(targetVersion) : null
23216
- };
23217
- await writeUpdateState2(options.paths, state);
23218
- await emitUpdateStatus2(options.paths);
23219
- return readLinkUpdateStatus(options.paths);
23161
+ function computeBackoffMs(attempt, baseMs, maxMs) {
23162
+ const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
23163
+ const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
23164
+ return exponential + jitter;
23220
23165
  }
23221
- async function readRemoteLinkPolicy(options) {
23222
- const context = await readLinkReleaseCheckContext(options.paths).catch(
23223
- () => null
23224
- );
23225
- try {
23226
- const response = await fetchCurrentLinkReleaseFromServer(
23227
- options,
23228
- options.fetchImpl ?? fetch
23229
- );
23230
- if (!response.ok) {
23231
- throw new Error(`HermesPilot Server returned HTTP ${response.status}`);
23232
- }
23233
- const snapshot = normalizeServerSnapshot(await response.json());
23234
- if (!snapshot.remote) {
23235
- return {
23236
- remote: null,
23237
- state: "unavailable",
23238
- issue: snapshot.issue ?? "HermesPilot Server has no Link release policy"
23239
- };
23166
+ async function handleFrame(socket, raw, localPort, abortControllers, streamBatchPolicy) {
23167
+ const frame = JSON.parse(raw);
23168
+ if (frame.type === "relay.config.update") {
23169
+ const nextPolicy = readRelayStreamBatchPolicy(frame.payload);
23170
+ if (nextPolicy) {
23171
+ streamBatchPolicy.current = nextPolicy;
23172
+ streamBatchPolicy.onUpdate?.(nextPolicy);
23240
23173
  }
23241
- return {
23242
- remote: snapshot.remote,
23243
- state: "fresh",
23244
- issue: snapshot.issue
23245
- };
23246
- } catch (error) {
23247
- const issue = error instanceof Error ? error.message : String(error);
23248
- void options.logger?.warn("link_release_server_check_failed", {
23249
- server_base_url: context?.serverBaseUrl ?? null,
23250
- release_check_url: context?.releaseCheckUrl ?? null,
23251
- error: issue
23252
- });
23253
- return { remote: null, state: "unavailable", issue };
23174
+ return;
23254
23175
  }
23255
- }
23256
- function normalizeServerSnapshot(payload) {
23257
- const snapshot = toRecord17(payload);
23258
- const policy = toNullableRecord2(snapshot.policy);
23259
- if (!policy) {
23260
- return {
23261
- remote: null,
23262
- issue: readString18(snapshot, "issue")
23263
- };
23176
+ if (frame.type === "http.cancel") {
23177
+ abortControllers.get(frame.id)?.abort();
23178
+ abortControllers.delete(frame.id);
23179
+ return;
23264
23180
  }
23265
- const release = toNullableRecord2(snapshot.release);
23266
- const currentVersion = readString18(policy, "current_version") ?? readString18(policy, "currentVersion");
23267
- const minSafeVersion = readString18(policy, "min_safe_version") ?? readString18(policy, "minSafeVersion");
23268
- if (!currentVersion) {
23269
- return {
23270
- remote: null,
23271
- issue: readString18(snapshot, "issue")
23272
- };
23181
+ if (frame.type !== "http.request") {
23182
+ return;
23273
23183
  }
23274
- return {
23275
- remote: {
23276
- current_version: currentVersion,
23277
- min_safe_version: minSafeVersion,
23278
- target_version: currentVersion,
23279
- release_url: release ? readString18(release, "release_url") ?? readString18(release, "releaseUrl") : null,
23280
- published_at: release ? readString18(release, "published_at") ?? readString18(release, "publishedAt") : null
23281
- },
23282
- issue: readString18(snapshot, "issue")
23283
- };
23284
- }
23285
- async function fetchCurrentLinkReleaseFromServer(options, fetcher) {
23286
- const config = await loadConfig(options.paths);
23287
- const url = new URL(SERVER_LINK_CURRENT_RELEASE_PATH, config.serverBaseUrl);
23288
- url.searchParams.set("channel", "stable");
23289
- url.searchParams.set("lang", "en");
23290
- const controller = new AbortController();
23291
- const timer = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS);
23184
+ const abortController = new AbortController();
23185
+ abortControllers.set(frame.id, abortController);
23186
+ let sseBatcher = null;
23292
23187
  try {
23293
- return await fetcher(url, {
23294
- headers: {
23295
- accept: "application/json",
23296
- "user-agent": `HermesPilot-Link/${LINK_VERSION}`
23297
- },
23298
- signal: controller.signal
23188
+ const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
23189
+ method: frame.method,
23190
+ headers: frame.headers ?? {},
23191
+ body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
23192
+ signal: abortController.signal
23299
23193
  });
23194
+ const headers = Object.fromEntries(response.headers.entries());
23195
+ const contentType = response.headers.get("content-type") ?? "";
23196
+ if (response.body && contentType.includes("text/event-stream")) {
23197
+ socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
23198
+ sseBatcher = createRelayStreamChunkBatcher(socket, frame.id, streamBatchPolicy);
23199
+ const reader = response.body.getReader();
23200
+ while (true) {
23201
+ const next = await reader.read();
23202
+ if (next.done) {
23203
+ break;
23204
+ }
23205
+ sseBatcher.push(next.value);
23206
+ }
23207
+ sseBatcher.flush();
23208
+ socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
23209
+ return;
23210
+ }
23211
+ const body = Buffer.from(await response.arrayBuffer()).toString("base64");
23212
+ socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
23300
23213
  } catch (error) {
23301
- if (error instanceof Error && error.name === "AbortError") {
23302
- throw new Error("HermesPilot Server Link release check timed out");
23214
+ if (abortController.signal.aborted || isAbortError2(error)) {
23215
+ return;
23303
23216
  }
23304
- throw error;
23217
+ sseBatcher?.flush();
23218
+ const message = error instanceof Error ? error.message : "Relay request failed";
23219
+ socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
23305
23220
  } finally {
23306
- clearTimeout(timer);
23221
+ sseBatcher?.dispose();
23222
+ abortControllers.delete(frame.id);
23307
23223
  }
23308
23224
  }
23309
- function buildOfficialInstallCommand(targetVersion) {
23310
- const env = {
23311
- HERMESLINK_VERSION: targetVersion,
23312
- HERMESLINK_YES: "1",
23313
- HERMESLINK_REQUIRE_RESTART_VERIFY: "1"
23225
+ function isAbortError2(error) {
23226
+ return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
23227
+ }
23228
+ function createRelayStreamChunkBatcher(socket, id, streamBatchPolicy) {
23229
+ let chunks = [];
23230
+ let totalBytes = 0;
23231
+ let flushTimer = null;
23232
+ const clearFlushTimer = () => {
23233
+ if (flushTimer == null) {
23234
+ return;
23235
+ }
23236
+ clearTimeout(flushTimer);
23237
+ flushTimer = null;
23314
23238
  };
23315
- if (process.platform === "win32") {
23316
- return {
23317
- command: `Invoke-RestMethod ${OFFICIAL_WINDOWS_INSTALLER_URL} | Invoke-Expression`,
23318
- displayCommand: `$env:HERMESLINK_VERSION="${targetVersion}"; $env:HERMESLINK_YES="1"; $env:HERMESLINK_REQUIRE_RESTART_VERIFY="1"; Invoke-RestMethod ${OFFICIAL_WINDOWS_INSTALLER_URL} | Invoke-Expression`,
23319
- env,
23320
- source: "official-installer"
23321
- };
23322
- }
23323
- return {
23324
- command: `curl -fsSL ${OFFICIAL_UNIX_INSTALLER_URL} | bash`,
23325
- displayCommand: `HERMESLINK_VERSION=${targetVersion} HERMESLINK_YES=1 HERMESLINK_REQUIRE_RESTART_VERIFY=1 sh -c 'curl -fsSL ${OFFICIAL_UNIX_INSTALLER_URL} | bash'`,
23326
- env,
23327
- source: "official-installer"
23239
+ const flush = () => {
23240
+ clearFlushTimer();
23241
+ if (totalBytes <= 0) {
23242
+ return;
23243
+ }
23244
+ const bodyBase64 = Buffer.concat(chunks, totalBytes).toString("base64");
23245
+ chunks = [];
23246
+ totalBytes = 0;
23247
+ if (socket.readyState !== WebSocket.OPEN) {
23248
+ return;
23249
+ }
23250
+ socket.send(JSON.stringify({ type: "http.stream.chunk", id, bodyBase64 }));
23328
23251
  };
23329
- }
23330
- function spawnInstallCommand(input) {
23331
- const env = {
23332
- ...process.env,
23333
- ...input.env
23252
+ const scheduleFlush = () => {
23253
+ if (flushTimer != null) {
23254
+ return;
23255
+ }
23256
+ flushTimer = setTimeout(() => {
23257
+ flushTimer = null;
23258
+ flush();
23259
+ }, streamBatchPolicy.current.flushIntervalMs);
23260
+ flushTimer.unref?.();
23334
23261
  };
23335
- if (process.platform === "win32") {
23336
- return spawn4(
23337
- "powershell.exe",
23338
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", input.command],
23339
- {
23340
- env,
23341
- stdio: ["ignore", "pipe", "pipe"],
23342
- windowsHide: true,
23343
- detached: false,
23344
- shell: false
23262
+ return {
23263
+ push(chunk) {
23264
+ if (chunk.byteLength <= 0) {
23265
+ return;
23345
23266
  }
23346
- );
23347
- }
23348
- return spawn4("/bin/sh", ["-lc", input.command], {
23349
- env,
23350
- stdio: ["ignore", "pipe", "pipe"],
23351
- windowsHide: true,
23352
- detached: false,
23353
- shell: false
23354
- });
23267
+ const buffer = Buffer.from(chunk);
23268
+ chunks.push(buffer);
23269
+ totalBytes += buffer.byteLength;
23270
+ if (totalBytes >= streamBatchPolicy.current.flushBytes) {
23271
+ flush();
23272
+ return;
23273
+ }
23274
+ scheduleFlush();
23275
+ },
23276
+ flush,
23277
+ dispose() {
23278
+ clearFlushTimer();
23279
+ chunks = [];
23280
+ totalBytes = 0;
23281
+ }
23282
+ };
23355
23283
  }
23356
- async function readLinkReleaseCheckContext(paths) {
23357
- const config = await loadConfig(paths);
23358
- const url = new URL(SERVER_LINK_CURRENT_RELEASE_PATH, config.serverBaseUrl);
23359
- url.searchParams.set("channel", "stable");
23360
- url.searchParams.set("lang", "en");
23284
+
23285
+ // src/runtime/system-info.ts
23286
+ import { execFileSync } from "child_process";
23287
+ import { readFileSync } from "fs";
23288
+ import os4 from "os";
23289
+ function readLinkSystemInfo() {
23290
+ const platform = process.platform;
23291
+ const hostname = readHostname(platform);
23292
+ const osLabel = readOsLabel(platform);
23293
+ const defaultDisplayName = buildDefaultDisplayName({ hostname, osLabel, platform });
23361
23294
  return {
23362
- serverBaseUrl: config.serverBaseUrl,
23363
- releaseCheckUrl: url.toString()
23295
+ platform,
23296
+ hostname,
23297
+ osLabel,
23298
+ defaultDisplayName
23364
23299
  };
23365
23300
  }
23366
- function computeLinkUpdateState(localVersion, remote) {
23367
- if (!remote?.current_version) {
23368
- return "unknown";
23369
- }
23370
- if (remote.min_safe_version && compareSemver3(localVersion, remote.min_safe_version) < 0) {
23371
- return "unsafe";
23372
- }
23373
- const diff = compareSemver3(localVersion, remote.current_version);
23374
- if (diff < 0) {
23375
- return "update_available";
23376
- }
23377
- if (diff > 0) {
23378
- return "ahead_of_current";
23301
+ function buildDefaultDisplayName(input) {
23302
+ const hostname = normalizeText(input.hostname);
23303
+ const osLabel = normalizeText(input.osLabel);
23304
+ if (hostname) {
23305
+ return truncateText(hostname, 128);
23379
23306
  }
23380
- return "current";
23381
- }
23382
- async function emitUpdateStatus2(paths) {
23383
- updateEvents2.emit("status", await readLinkUpdateStatus(paths));
23384
- }
23385
- async function writeUpdateState2(paths, state) {
23386
- await writeJsonFile(updateStatePath2(paths), state);
23307
+ return truncateText(hostname ?? osLabel ?? `Hermes Link ${input.platform}`, 128);
23387
23308
  }
23388
- async function readUpdateLogLines2(paths) {
23389
- const raw = await readFile17(updateLogPath2(paths), "utf8").catch(() => "");
23390
- if (!raw.trim()) {
23391
- return [];
23309
+ function parseLinuxOsRelease(content) {
23310
+ const values = /* @__PURE__ */ new Map();
23311
+ for (const line of content.split(/\r?\n/u)) {
23312
+ const match = /^([A-Z0-9_]+)=(.*)$/u.exec(line.trim());
23313
+ if (!match) {
23314
+ continue;
23315
+ }
23316
+ values.set(match[1], unquoteOsReleaseValue(match[2]));
23392
23317
  }
23393
- return raw.split(/\r?\n/u).map((line) => line.trimEnd()).filter(Boolean).slice(-MAX_UPDATE_LOG_LINES2).map(
23394
- (line) => line.length > MAX_OUTPUT_LINE_LENGTH3 ? `${line.slice(0, MAX_OUTPUT_LINE_LENGTH3)}...` : line
23395
- );
23318
+ return normalizeText(values.get("PRETTY_NAME")) ?? buildLinuxName(values);
23396
23319
  }
23397
- function updateStatePath2(paths) {
23398
- return path24.join(paths.runDir, "link-update-state.json");
23320
+ function readHostname(platform) {
23321
+ if (platform === "darwin") {
23322
+ const computerName = readCommandOutput("scutil", ["--get", "ComputerName"]);
23323
+ if (computerName) {
23324
+ return computerName;
23325
+ }
23326
+ }
23327
+ return normalizeText(os4.hostname());
23399
23328
  }
23400
- function updateLogPath2(paths) {
23401
- return path24.join(paths.logsDir, UPDATE_LOG_FILE2);
23329
+ function readOsLabel(platform) {
23330
+ if (platform === "darwin") {
23331
+ const version = readCommandOutput("sw_vers", ["-productVersion"]);
23332
+ return version ? `macOS ${version}` : "macOS";
23333
+ }
23334
+ if (platform === "linux") {
23335
+ return readLinuxOsRelease() ?? `Linux ${os4.release()}`;
23336
+ }
23337
+ if (platform === "win32") {
23338
+ return `Windows ${os4.release()}`;
23339
+ }
23340
+ return `${os4.type()} ${os4.release()}`.trim();
23402
23341
  }
23403
- async function clearUpdateLogFiles2(paths) {
23404
- const primary = updateLogPath2(paths);
23405
- await Promise.all([
23406
- rm8(primary, { force: true }).catch(() => void 0),
23407
- ...Array.from(
23408
- { length: UPDATE_LOG_MAX_FILES2 },
23409
- (_, index) => rm8(`${primary}.${index + 1}`, { force: true }).catch(() => void 0)
23410
- )
23411
- ]);
23412
- }
23413
- function manualInstallCommand(version) {
23414
- return `npm install -g ${LINK_NPM_PACKAGE}@${version}`;
23415
- }
23416
- function isValidReleaseVersion(version) {
23417
- return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(
23418
- version
23419
- );
23420
- }
23421
- function compareSemver3(left, right) {
23422
- const leftParts = parseSemver(left);
23423
- const rightParts = parseSemver(right);
23424
- for (let index = 0; index < 3; index += 1) {
23425
- const diff = leftParts[index] - rightParts[index];
23426
- if (diff !== 0) {
23427
- return diff;
23342
+ function readLinuxOsRelease() {
23343
+ for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
23344
+ try {
23345
+ return parseLinuxOsRelease(readFileSync(file, "utf8"));
23346
+ } catch {
23428
23347
  }
23429
23348
  }
23430
- return 0;
23431
- }
23432
- function parseSemver(value) {
23433
- const match = /^v?(\d+)\.(\d+)\.(\d+)/u.exec(value.trim());
23434
- return [
23435
- Number.parseInt(match?.[1] ?? "0", 10),
23436
- Number.parseInt(match?.[2] ?? "0", 10),
23437
- Number.parseInt(match?.[3] ?? "0", 10)
23438
- ];
23439
- }
23440
- function isRecentRunningState3(state, now = Date.now()) {
23441
- const startedAt = state.started_at ? Date.parse(state.started_at) : Number.NaN;
23442
- return Number.isFinite(startedAt) && now - startedAt < 1e4;
23349
+ return null;
23443
23350
  }
23444
- function isProcessAlive3(pid) {
23445
- if (!pid || pid <= 0) {
23446
- return false;
23447
- }
23351
+ function readCommandOutput(command, args) {
23448
23352
  try {
23449
- process.kill(pid, 0);
23450
- return true;
23353
+ const output = execFileSync(command, args, {
23354
+ encoding: "utf8",
23355
+ stdio: ["ignore", "pipe", "ignore"],
23356
+ timeout: 1e3
23357
+ });
23358
+ return normalizeText(output);
23451
23359
  } catch {
23452
- return false;
23360
+ return null;
23453
23361
  }
23454
23362
  }
23455
- function toRecord17(value) {
23456
- return typeof value === "object" && value !== null ? value : {};
23363
+ function buildLinuxName(values) {
23364
+ const name = normalizeText(values.get("NAME"));
23365
+ const version = normalizeText(values.get("VERSION_ID")) ?? normalizeText(values.get("VERSION"));
23366
+ if (name && version) {
23367
+ return `${name} ${version}`;
23368
+ }
23369
+ return name ?? version;
23457
23370
  }
23458
- function toNullableRecord2(value) {
23459
- return typeof value === "object" && value !== null ? value : null;
23371
+ function unquoteOsReleaseValue(value) {
23372
+ const trimmed = value.trim();
23373
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
23374
+ return trimmed.slice(1, -1).replace(/\\(["'`$\\])/gu, "$1");
23375
+ }
23376
+ return trimmed;
23460
23377
  }
23461
- function readString18(payload, key) {
23462
- const value = payload[key];
23463
- return typeof value === "string" && value.trim() ? value.trim() : null;
23378
+ function normalizeText(value) {
23379
+ const normalized = value?.replace(/\s+/gu, " ").trim();
23380
+ return normalized ? normalized : null;
23381
+ }
23382
+ function truncateText(value, maxLength) {
23383
+ return value.length > maxLength ? value.slice(0, maxLength).trimEnd() : value;
23464
23384
  }
23465
23385
 
23466
- // src/pairing/pairing.ts
23467
- import path25 from "path";
23468
- import { rm as rm9 } from "fs/promises";
23386
+ // src/topology/network.ts
23387
+ import os6 from "os";
23469
23388
 
23470
- // src/relay/bootstrap.ts
23471
- var RelayNetworkError = class extends Error {
23472
- constructor(relayBaseUrl, causeMessage) {
23473
- super(
23474
- `Cannot reach Hermes Relay at ${relayBaseUrl}. Please check your network connection, VPN, or proxy settings, then try again.`
23475
- );
23476
- this.relayBaseUrl = relayBaseUrl;
23477
- this.causeMessage = causeMessage;
23478
- }
23479
- relayBaseUrl;
23480
- causeMessage;
23481
- };
23482
- async function bootstrapRelayLink(options) {
23483
- const fetcher = options.fetchImpl ?? fetch;
23484
- const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
23485
- const commonPayload = {
23486
- install_id: options.identity.install_id,
23487
- link_id: options.identity.link_id ?? void 0,
23488
- public_key_pem: options.identity.public_key_pem
23489
- };
23490
- const challenge = await postJson(
23491
- fetcher,
23492
- `${baseUrl}/api/v1/relay/link/challenge`,
23493
- options.relayBootstrapToken,
23494
- commonPayload
23495
- );
23496
- if (challenge.ok !== true || typeof challenge.nonce !== "string") {
23497
- throw new Error("Relay did not return a valid install challenge");
23389
+ // src/topology/environment.ts
23390
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
23391
+ import os5 from "os";
23392
+ function detectRuntimeEnvironment(env = process.env) {
23393
+ if (isWsl(env)) {
23394
+ return {
23395
+ kind: "wsl",
23396
+ lanAutoDiscoveryUsable: false,
23397
+ warning: "Detected WSL. The LAN IP found inside WSL is usually a private VM address and is not reachable from your phone. Use Relay or set `hermeslink config set lan-host <Windows LAN IP>`."
23398
+ };
23498
23399
  }
23499
- const proof = {
23500
- nonce: challenge.nonce,
23501
- signature: signRelayNonce(options.identity, challenge.nonce)
23502
- };
23503
- const assigned = await postJson(
23504
- fetcher,
23505
- `${baseUrl}/api/v1/relay/link/bootstrap`,
23506
- options.relayBootstrapToken,
23507
- {
23508
- ...commonPayload,
23509
- proof
23510
- }
23511
- );
23512
- if (assigned.ok !== true || typeof assigned.link_id !== "string") {
23513
- throw new Error("Relay did not return a valid link_id");
23400
+ if (isContainer(env)) {
23401
+ return {
23402
+ kind: "container",
23403
+ lanAutoDiscoveryUsable: false,
23404
+ warning: "Detected a container environment. Container LAN IPs are usually not reachable from your phone. Use Relay or set `hermeslink config set lan-host <host LAN IP>`."
23405
+ };
23514
23406
  }
23515
- await saveAssignedLinkId(assigned.link_id, options.paths ?? resolveRuntimePaths());
23516
23407
  return {
23517
- linkId: assigned.link_id,
23518
- reused: assigned.reused === true
23408
+ kind: "native",
23409
+ lanAutoDiscoveryUsable: true,
23410
+ warning: null
23519
23411
  };
23520
23412
  }
23521
- async function postJson(fetcher, url, token, body) {
23522
- let response;
23523
- try {
23524
- response = await fetcher(url, {
23525
- method: "POST",
23526
- headers: {
23527
- authorization: `Bearer ${token}`,
23528
- "content-type": "application/json"
23529
- },
23530
- body: JSON.stringify(body)
23531
- });
23532
- } catch (error) {
23533
- const baseUrl = new URL(url).origin;
23534
- throw new RelayNetworkError(
23535
- baseUrl,
23536
- error instanceof Error ? error.message : String(error)
23537
- );
23538
- }
23539
- const payload = await response.json().catch(() => null);
23540
- if (!response.ok) {
23541
- const message = readErrorMessage3(payload) ?? `Relay request failed with HTTP ${response.status}`;
23542
- throw new Error(message);
23413
+ function isWsl(env) {
23414
+ if (process.platform !== "linux") {
23415
+ return false;
23543
23416
  }
23544
- if (!payload) {
23545
- throw new Error("Relay returned an empty response");
23417
+ if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
23418
+ return true;
23546
23419
  }
23547
- return payload;
23420
+ const release = os5.release().toLowerCase();
23421
+ return release.includes("microsoft") || release.includes("wsl");
23548
23422
  }
23549
- function readErrorMessage3(payload) {
23550
- if (typeof payload !== "object" || payload === null) {
23551
- return null;
23423
+ function isContainer(env) {
23424
+ if (env.container || env.CONTAINER || env.KUBERNETES_SERVICE_HOST) {
23425
+ return true;
23552
23426
  }
23553
- const error = payload.error;
23554
- if (typeof error !== "object" || error === null) {
23555
- return null;
23427
+ if (existsSync("/.dockerenv")) {
23428
+ return true;
23429
+ }
23430
+ try {
23431
+ const cgroup = readFileSync2("/proc/1/cgroup", "utf8").toLowerCase();
23432
+ return /docker|containerd|kubepods|libpod|podman/u.test(cgroup);
23433
+ } catch {
23434
+ return false;
23556
23435
  }
23557
- const message = error.message;
23558
- return typeof message === "string" ? message : null;
23559
23436
  }
23560
23437
 
23561
- // src/runtime/system-info.ts
23562
- import { execFileSync } from "child_process";
23563
- import { readFileSync } from "fs";
23564
- import os4 from "os";
23565
- function readLinkSystemInfo() {
23566
- const platform = process.platform;
23567
- const hostname = readHostname(platform);
23568
- const osLabel = readOsLabel(platform);
23569
- const defaultDisplayName = buildDefaultDisplayName({ hostname, osLabel, platform });
23438
+ // src/topology/network.ts
23439
+ var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
23440
+ var MAX_LAN_IPS = 4;
23441
+ var MAX_PUBLIC_IPV4S = 2;
23442
+ var MAX_PUBLIC_IPV6S = 2;
23443
+ async function discoverRouteCandidates(options) {
23444
+ const environment = detectRuntimeEnvironment();
23445
+ const configuredLanHost = normalizeLanHost(options.configuredLanHost);
23446
+ const lanIps = configuredLanHost ? [configuredLanHost] : environment.lanAutoDiscoveryUsable ? discoverLanIps() : [];
23447
+ const publicIps = options.relayBootstrapToken || options.observePublicRoute ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
23448
+ const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
23449
+ const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
23450
+ const preferredUrls = [
23451
+ ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
23452
+ ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
23453
+ ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
23454
+ `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
23455
+ ];
23570
23456
  return {
23571
- platform,
23572
- hostname,
23573
- osLabel,
23574
- defaultDisplayName
23457
+ lanIps,
23458
+ publicIpv4s,
23459
+ publicIpv6s,
23460
+ preferredUrls,
23461
+ environment
23575
23462
  };
23576
23463
  }
23577
- function buildDefaultDisplayName(input) {
23578
- const hostname = normalizeText(input.hostname);
23579
- const osLabel = normalizeText(input.osLabel);
23580
- if (hostname) {
23581
- return truncateText(hostname, 128);
23582
- }
23583
- return truncateText(hostname ?? osLabel ?? `Hermes Link ${input.platform}`, 128);
23464
+ function discoverLanIps() {
23465
+ return discoverLanIpsFromInterfaces(os6.networkInterfaces());
23584
23466
  }
23585
- function parseLinuxOsRelease(content) {
23586
- const values = /* @__PURE__ */ new Map();
23587
- for (const line of content.split(/\r?\n/u)) {
23588
- const match = /^([A-Z0-9_]+)=(.*)$/u.exec(line.trim());
23589
- if (!match) {
23467
+ function discoverLanIpsFromInterfaces(interfaces) {
23468
+ const result = /* @__PURE__ */ new Set();
23469
+ const candidates = [];
23470
+ for (const [name, items] of Object.entries(interfaces)) {
23471
+ if (shouldIgnoreInterface(name)) {
23590
23472
  continue;
23591
23473
  }
23592
- values.set(match[1], unquoteOsReleaseValue(match[2]));
23593
- }
23594
- return normalizeText(values.get("PRETTY_NAME")) ?? buildLinuxName(values);
23474
+ for (const item of items ?? []) {
23475
+ if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv42(item.address, item.netmask)) {
23476
+ candidates.push({ name, address: item.address });
23477
+ }
23478
+ }
23479
+ }
23480
+ for (const candidate of candidates.sort(compareLanCandidate)) {
23481
+ result.add(candidate.address);
23482
+ }
23483
+ return [...result].slice(0, MAX_LAN_IPS);
23595
23484
  }
23596
- function readHostname(platform) {
23597
- if (platform === "darwin") {
23598
- const computerName = readCommandOutput("scutil", ["--get", "ComputerName"]);
23599
- if (computerName) {
23600
- return computerName;
23485
+ async function observePublicRoute(options) {
23486
+ const fetcher = options.fetchImpl ?? fetch;
23487
+ const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
23488
+ method: "POST",
23489
+ headers: {
23490
+ "content-type": "application/json",
23491
+ ...options.relayBootstrapToken ? { authorization: `Bearer ${options.relayBootstrapToken}` } : {}
23492
+ },
23493
+ body: JSON.stringify({
23494
+ install_id: options.installId,
23495
+ link_id: options.linkId,
23496
+ public_key_pem: options.publicKeyPem
23497
+ })
23498
+ });
23499
+ const payload = await response.json().catch(() => null);
23500
+ const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
23501
+ const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
23502
+ const values = [
23503
+ readIpRecord(record?.ipv4),
23504
+ readIpRecord(record?.ipv6),
23505
+ typeof observed?.ip === "string" ? observed.ip : null
23506
+ ].filter((value) => Boolean(value));
23507
+ return {
23508
+ publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
23509
+ publicIpv6s: unique(values.filter(isUsablePublicIpv6))
23510
+ };
23511
+ }
23512
+ function readIpRecord(value) {
23513
+ if (typeof value !== "object" || value === null) {
23514
+ return null;
23515
+ }
23516
+ const ip = value.ip;
23517
+ return typeof ip === "string" && ip.trim() ? ip.trim() : null;
23518
+ }
23519
+ function buildDirectUrl(ip, port) {
23520
+ return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
23521
+ }
23522
+ function shouldIgnoreInterface(name) {
23523
+ return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
23524
+ }
23525
+ function compareLanCandidate(left, right) {
23526
+ const priority = interfacePriority(left.name) - interfacePriority(right.name);
23527
+ return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
23528
+ }
23529
+ function interfacePriority(name) {
23530
+ if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
23531
+ return 0;
23532
+ }
23533
+ return 1;
23534
+ }
23535
+ function isUsableLanIpv42(address, netmask) {
23536
+ return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
23537
+ }
23538
+ function isUsablePublicIpv4(address) {
23539
+ return isValidIpv4(address) && !isSpecialIpv4(address);
23540
+ }
23541
+ function isUsablePublicIpv6(address) {
23542
+ const normalized = address.toLowerCase();
23543
+ return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
23544
+ }
23545
+ function isPrivateIpv4(address) {
23546
+ const parts = parseIpv4Segments(address);
23547
+ if (!parts) {
23548
+ return false;
23549
+ }
23550
+ const [first, second] = parts;
23551
+ return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
23552
+ }
23553
+ function isSpecialIpv4(address) {
23554
+ const parts = parseIpv4Segments(address);
23555
+ if (!parts) {
23556
+ return true;
23557
+ }
23558
+ const [first, second, third, fourth] = parts;
23559
+ return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
23560
+ }
23561
+ function isNetworkOrBroadcastIpv4Address(address, netmask) {
23562
+ const addressParts = parseIpv4Segments(address);
23563
+ const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
23564
+ if (!addressParts) {
23565
+ return true;
23566
+ }
23567
+ if (!netmaskParts) {
23568
+ const last = addressParts[3];
23569
+ return last === 0 || last === 255;
23570
+ }
23571
+ const addressInt = ipv4SegmentsToInt(addressParts);
23572
+ const netmaskInt = ipv4SegmentsToInt(netmaskParts);
23573
+ const hostMask = ~netmaskInt >>> 0;
23574
+ if (hostMask === 0) {
23575
+ return false;
23576
+ }
23577
+ const networkInt = addressInt & netmaskInt;
23578
+ const broadcastInt = (networkInt | hostMask) >>> 0;
23579
+ return addressInt === networkInt || addressInt === broadcastInt;
23580
+ }
23581
+ function isValidIpv4(address) {
23582
+ return Boolean(parseIpv4Segments(address));
23583
+ }
23584
+ function parseIpv4Segments(address) {
23585
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
23586
+ return null;
23587
+ }
23588
+ const parts = address.split(".").map((part) => Number.parseInt(part, 10));
23589
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
23590
+ return null;
23591
+ }
23592
+ return parts;
23593
+ }
23594
+ function ipv4SegmentsToInt(parts) {
23595
+ return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
23596
+ }
23597
+ function unique(values) {
23598
+ return [...new Set(values)];
23599
+ }
23600
+
23601
+ // src/link/network-report-state.ts
23602
+ var DEFAULT_AUTO_DAILY_LIMIT = 20;
23603
+ async function readNetworkReportState(paths) {
23604
+ const state = await readLinkState(paths);
23605
+ return normalizeNetworkReportState(state.networkReport);
23606
+ }
23607
+ async function markNetworkStatusReported(paths, snapshotInput, reportedAt = /* @__PURE__ */ new Date()) {
23608
+ const snapshot = normalizeNetworkSnapshot(snapshotInput);
23609
+ await updateNetworkReportState(paths, (current) => ({
23610
+ ...current,
23611
+ lastReportedLanIps: snapshot.lanIps,
23612
+ lastReportedPublicIpv4s: snapshot.publicIpv4s,
23613
+ lastReportedPublicIpv6s: snapshot.publicIpv6s,
23614
+ lastReportedAt: reportedAt.toISOString(),
23615
+ lastAutoAttempt: current.lastAutoAttempt ? { ...current.lastAutoAttempt, ...snapshot, success: true } : null
23616
+ }));
23617
+ }
23618
+ async function reserveAutomaticNetworkReport(paths, snapshotInput, options = {}) {
23619
+ const snapshot = normalizeNetworkSnapshot(snapshotInput);
23620
+ const now = options.now ?? /* @__PURE__ */ new Date();
23621
+ const dailyLimit = Math.max(0, Math.floor(options.dailyLimit ?? DEFAULT_AUTO_DAILY_LIMIT));
23622
+ let reservation = { allowed: false, reason: "unchanged" };
23623
+ await updateNetworkReportState(paths, (current) => {
23624
+ if (sameNetworkSnapshot(readReportedSnapshot(current), snapshot)) {
23625
+ const lastReportedAt = current.lastReportedAt ? Date.parse(current.lastReportedAt) : Number.NaN;
23626
+ const unchangedMinIntervalMs = Math.max(0, Math.floor(options.unchangedMinIntervalMs ?? 0));
23627
+ if (!options.force || Number.isFinite(lastReportedAt) && now.getTime() - lastReportedAt < unchangedMinIntervalMs) {
23628
+ reservation = { allowed: false, reason: "unchanged" };
23629
+ return current;
23630
+ }
23631
+ }
23632
+ if (current.lastAutoAttempt && !current.lastAutoAttempt.success && sameNetworkSnapshot(readAttemptSnapshot(current.lastAutoAttempt), snapshot)) {
23633
+ reservation = { allowed: false, reason: "failed_snapshot_not_retried" };
23634
+ return current;
23601
23635
  }
23636
+ const quotaDay = formatUtcDay(now);
23637
+ const reportsToday = current.autoQuotaDay === quotaDay ? current.autoReportsToday : 0;
23638
+ if (reportsToday >= dailyLimit) {
23639
+ reservation = { allowed: false, reason: "daily_limit_reached" };
23640
+ return current;
23641
+ }
23642
+ reservation = { allowed: true };
23643
+ return {
23644
+ ...current,
23645
+ autoQuotaDay: quotaDay,
23646
+ autoReportsToday: reportsToday + 1,
23647
+ lastAutoAttempt: {
23648
+ ...snapshot,
23649
+ attemptedAt: now.toISOString(),
23650
+ success: false
23651
+ }
23652
+ };
23653
+ });
23654
+ return reservation;
23655
+ }
23656
+ async function mergeLastReportedPublicRoutes(paths, snapshotInput) {
23657
+ const state = await readNetworkReportState(paths);
23658
+ return {
23659
+ ...snapshotInput,
23660
+ publicIpv4s: uniqueStrings([
23661
+ ...snapshotInput.publicIpv4s,
23662
+ ...state.lastReportedPublicIpv4s
23663
+ ]).slice(0, 2),
23664
+ publicIpv6s: uniqueStrings([
23665
+ ...snapshotInput.publicIpv6s,
23666
+ ...state.lastReportedPublicIpv6s
23667
+ ]).slice(0, 2)
23668
+ };
23669
+ }
23670
+ async function updateNetworkReportState(paths, update) {
23671
+ const state = await readLinkState(paths);
23672
+ const next = {
23673
+ ...state,
23674
+ networkReport: update(normalizeNetworkReportState(state.networkReport))
23675
+ };
23676
+ await writeJsonFile(paths.stateFile, next);
23677
+ }
23678
+ async function readLinkState(paths) {
23679
+ const state = await readJsonFile(paths.stateFile);
23680
+ return state && typeof state === "object" ? state : {};
23681
+ }
23682
+ function normalizeNetworkReportState(value) {
23683
+ const record = value && typeof value === "object" ? value : {};
23684
+ return {
23685
+ lastReportedLanIps: normalizeLanIps(record.lastReportedLanIps),
23686
+ lastReportedPublicIpv4s: normalizeLanIps(record.lastReportedPublicIpv4s),
23687
+ lastReportedPublicIpv6s: normalizeLanIps(record.lastReportedPublicIpv6s),
23688
+ lastReportedAt: typeof record.lastReportedAt === "string" ? record.lastReportedAt : null,
23689
+ autoQuotaDay: typeof record.autoQuotaDay === "string" ? record.autoQuotaDay : null,
23690
+ autoReportsToday: Number.isFinite(record.autoReportsToday) ? Math.max(0, Math.floor(record.autoReportsToday ?? 0)) : 0,
23691
+ lastAutoAttempt: normalizeAttempt(record.lastAutoAttempt)
23692
+ };
23693
+ }
23694
+ function normalizeAttempt(value) {
23695
+ if (!value || typeof value !== "object") {
23696
+ return null;
23602
23697
  }
23603
- return normalizeText(os4.hostname());
23698
+ const record = value;
23699
+ if (typeof record.attemptedAt !== "string") {
23700
+ return null;
23701
+ }
23702
+ return {
23703
+ lanIps: normalizeLanIps(record.lanIps),
23704
+ publicIpv4s: normalizeLanIps(record.publicIpv4s),
23705
+ publicIpv6s: normalizeLanIps(record.publicIpv6s),
23706
+ attemptedAt: record.attemptedAt,
23707
+ success: record.success === true
23708
+ };
23604
23709
  }
23605
- function readOsLabel(platform) {
23606
- if (platform === "darwin") {
23607
- const version = readCommandOutput("sw_vers", ["-productVersion"]);
23608
- return version ? `macOS ${version}` : "macOS";
23710
+ function normalizeNetworkSnapshot(value) {
23711
+ if (Array.isArray(value)) {
23712
+ return {
23713
+ lanIps: normalizeLanIps(value),
23714
+ publicIpv4s: [],
23715
+ publicIpv6s: []
23716
+ };
23609
23717
  }
23610
- if (platform === "linux") {
23611
- return readLinuxOsRelease() ?? `Linux ${os4.release()}`;
23718
+ const record = value && typeof value === "object" ? value : {};
23719
+ return {
23720
+ lanIps: normalizeLanIps(record.lanIps),
23721
+ publicIpv4s: normalizeLanIps(record.publicIpv4s),
23722
+ publicIpv6s: normalizeLanIps(record.publicIpv6s)
23723
+ };
23724
+ }
23725
+ function readReportedSnapshot(state) {
23726
+ return {
23727
+ lanIps: state.lastReportedLanIps,
23728
+ publicIpv4s: state.lastReportedPublicIpv4s,
23729
+ publicIpv6s: state.lastReportedPublicIpv6s
23730
+ };
23731
+ }
23732
+ function readAttemptSnapshot(attempt) {
23733
+ return {
23734
+ lanIps: attempt.lanIps,
23735
+ publicIpv4s: attempt.publicIpv4s,
23736
+ publicIpv6s: attempt.publicIpv6s
23737
+ };
23738
+ }
23739
+ function normalizeLanIps(value) {
23740
+ if (!Array.isArray(value)) {
23741
+ return [];
23612
23742
  }
23613
- if (platform === "win32") {
23614
- return `Windows ${os4.release()}`;
23743
+ return [
23744
+ ...new Set(
23745
+ value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim())
23746
+ )
23747
+ ];
23748
+ }
23749
+ function sameNetworkSnapshot(left, right) {
23750
+ return sameStringList(left.lanIps, right.lanIps) && sameStringList(left.publicIpv4s, right.publicIpv4s) && sameStringList(left.publicIpv6s, right.publicIpv6s);
23751
+ }
23752
+ function sameStringList(left, right) {
23753
+ if (left.length !== right.length) {
23754
+ return false;
23755
+ }
23756
+ return left.every((value, index) => value === right[index]);
23757
+ }
23758
+ function uniqueStrings(values) {
23759
+ return [...new Set(values)];
23760
+ }
23761
+ function formatUtcDay(date) {
23762
+ return date.toISOString().slice(0, 10);
23763
+ }
23764
+
23765
+ // src/link/server-report.ts
23766
+ async function reportLinkStatusToServer(options = {}) {
23767
+ const paths = options.paths ?? resolveRuntimePaths();
23768
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
23769
+ if (!identity?.link_id) {
23770
+ return null;
23771
+ }
23772
+ const discoveredRoutes = options.routes ?? await discoverRouteCandidates({
23773
+ port: config.port,
23774
+ relayBaseUrl: config.relayBaseUrl,
23775
+ linkId: identity.link_id,
23776
+ installId: identity.install_id,
23777
+ publicKeyPem: identity.public_key_pem,
23778
+ observePublicRoute: true,
23779
+ configuredLanHost: config.lanHost,
23780
+ fetchImpl: options.fetchImpl
23781
+ });
23782
+ const routes = await mergeLastReportedPublicRoutes(paths, discoveredRoutes);
23783
+ const systemInfo = readLinkSystemInfo();
23784
+ const payload = {
23785
+ type: "hermes_link_status_report",
23786
+ link_id: identity.link_id,
23787
+ install_id: identity.install_id,
23788
+ link_version: LINK_VERSION,
23789
+ display_name: systemInfo.defaultDisplayName,
23790
+ platform: systemInfo.platform,
23791
+ hostname: systemInfo.hostname ?? void 0,
23792
+ lan_ips: routes.lanIps,
23793
+ public_ipv4s: routes.publicIpv4s,
23794
+ public_ipv6s: routes.publicIpv6s,
23795
+ reported_at: (/* @__PURE__ */ new Date()).toISOString()
23796
+ };
23797
+ const signature = signIdentityPayload(identity, canonicalJson(payload));
23798
+ const fetcher = options.fetchImpl ?? fetch;
23799
+ const response = await fetcher(
23800
+ `${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/links/${encodeURIComponent(identity.link_id)}/report`,
23801
+ {
23802
+ method: "POST",
23803
+ headers: {
23804
+ accept: "application/json",
23805
+ "content-type": "application/json"
23806
+ },
23807
+ body: JSON.stringify({
23808
+ ...payload,
23809
+ public_key_pem: identity.public_key_pem,
23810
+ signature
23811
+ })
23812
+ }
23813
+ );
23814
+ const body = await response.json().catch(() => null);
23815
+ if (!response.ok || !body) {
23816
+ const message = readErrorMessage3(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
23817
+ throw new LinkHttpError(response.status, "server_request_failed", message);
23818
+ }
23819
+ await markNetworkStatusReported(paths, routes);
23820
+ return body;
23821
+ }
23822
+ function canonicalJson(value) {
23823
+ return JSON.stringify(sortJsonValue(value));
23824
+ }
23825
+ function sortJsonValue(value) {
23826
+ if (Array.isArray(value)) {
23827
+ return value.map(sortJsonValue);
23828
+ }
23829
+ if (value && typeof value === "object") {
23830
+ const record = value;
23831
+ const sorted = {};
23832
+ for (const key of Object.keys(record).sort()) {
23833
+ sorted[key] = sortJsonValue(record[key]);
23834
+ }
23835
+ return sorted;
23836
+ }
23837
+ return value;
23838
+ }
23839
+ function readErrorMessage3(payload) {
23840
+ if (typeof payload !== "object" || payload === null) {
23841
+ return null;
23842
+ }
23843
+ const error = payload.error;
23844
+ if (typeof error !== "object" || error === null) {
23845
+ return null;
23846
+ }
23847
+ const message = error.message;
23848
+ return typeof message === "string" ? message : null;
23849
+ }
23850
+
23851
+ // src/daemon/lan-ip-monitor.ts
23852
+ var DEFAULT_INTERVAL_MS = 5 * 6e4;
23853
+ var DEFAULT_DAILY_REPORT_LIMIT = 20;
23854
+ var DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS = 15 * 6e4;
23855
+ function startLanIpMonitor(options) {
23856
+ let running = false;
23857
+ let closed = false;
23858
+ let current = Promise.resolve();
23859
+ const check = async (context = {}) => {
23860
+ if (running || closed) {
23861
+ return;
23862
+ }
23863
+ running = true;
23864
+ try {
23865
+ await checkLanIpChange(options, context);
23866
+ } catch (error) {
23867
+ void options.logger.warn("lan_ip_monitor_failed", {
23868
+ error: error instanceof Error ? error.message : String(error)
23869
+ });
23870
+ } finally {
23871
+ running = false;
23872
+ }
23873
+ };
23874
+ current = check({ forceReport: true, publishToRelay: true, observePublicRoute: true });
23875
+ const timer = setInterval(() => {
23876
+ current = check({ observePublicRoute: false });
23877
+ }, options.intervalMs ?? DEFAULT_INTERVAL_MS);
23878
+ timer.unref?.();
23879
+ return {
23880
+ async refreshPublicRoutes() {
23881
+ current = check({ forceReport: true, publishToRelay: true, observePublicRoute: true });
23882
+ await current;
23883
+ },
23884
+ async close() {
23885
+ closed = true;
23886
+ clearInterval(timer);
23887
+ await current.catch(() => void 0);
23888
+ }
23889
+ };
23890
+ }
23891
+ async function checkLanIpChange(options, context = {}) {
23892
+ const [identity, config] = await Promise.all([
23893
+ loadIdentity(options.paths),
23894
+ loadConfig(options.paths)
23895
+ ]);
23896
+ if (!identity?.link_id) {
23897
+ return;
23898
+ }
23899
+ const discoveredRoutes = await discoverRouteCandidates({
23900
+ port: config.port,
23901
+ relayBaseUrl: config.relayBaseUrl,
23902
+ linkId: identity.link_id,
23903
+ installId: identity.install_id,
23904
+ publicKeyPem: identity.public_key_pem,
23905
+ observePublicRoute: context.observePublicRoute === true,
23906
+ configuredLanHost: config.lanHost,
23907
+ fetchImpl: options.fetchImpl
23908
+ });
23909
+ const routes = await mergeLastReportedPublicRoutes(options.paths, discoveredRoutes);
23910
+ if (context.publishToRelay) {
23911
+ options.onNetworkRoutes?.(routes);
23912
+ }
23913
+ const reservation = await reserveAutomaticNetworkReport(options.paths, routes, {
23914
+ dailyLimit: options.dailyReportLimit ?? DEFAULT_DAILY_REPORT_LIMIT,
23915
+ force: context.forceReport === true,
23916
+ unchangedMinIntervalMs: options.startupReportMinIntervalMs ?? DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS
23917
+ });
23918
+ if (!reservation.allowed) {
23919
+ const logFields = {
23920
+ lan_ips: routes.lanIps,
23921
+ public_ipv4s: routes.publicIpv4s,
23922
+ public_ipv6s: routes.publicIpv6s,
23923
+ reason: reservation.reason
23924
+ };
23925
+ void options.logger.debug("lan_ip_report_skipped", logFields);
23926
+ return;
23927
+ }
23928
+ try {
23929
+ const result = await reportLinkStatusToServer({
23930
+ paths: options.paths,
23931
+ fetchImpl: options.fetchImpl,
23932
+ routes
23933
+ });
23934
+ if (result) {
23935
+ options.onNetworkRoutes?.(routes);
23936
+ void options.logger.info("lan_ip_change_reported", {
23937
+ link_id: result.linkId,
23938
+ lan_ips: routes.lanIps,
23939
+ public_ipv4s: routes.publicIpv4s,
23940
+ public_ipv6s: routes.publicIpv6s
23941
+ });
23942
+ }
23943
+ } catch (error) {
23944
+ void options.logger.warn("lan_ip_change_report_failed", {
23945
+ lan_ips: routes.lanIps,
23946
+ error: error instanceof Error ? error.message : String(error)
23947
+ });
23948
+ }
23949
+ }
23950
+
23951
+ // src/daemon/scheduler.ts
23952
+ function startCronDeliveryScheduler(options) {
23953
+ let running = false;
23954
+ let current = Promise.resolve();
23955
+ const syncCronDeliveries = async () => {
23956
+ if (running) {
23957
+ return;
23958
+ }
23959
+ running = true;
23960
+ try {
23961
+ await syncHermesLinkCronDeliveries(
23962
+ options.paths,
23963
+ options.conversations,
23964
+ options.logger
23965
+ );
23966
+ } catch (error) {
23967
+ void options.logger.warn("cron_link_delivery_sync_failed", {
23968
+ source: "daemon_scheduler",
23969
+ error: error instanceof Error ? error.message : String(error)
23970
+ });
23971
+ } finally {
23972
+ running = false;
23973
+ }
23974
+ };
23975
+ const timer = setInterval(() => {
23976
+ current = syncCronDeliveries();
23977
+ }, options.intervalMs ?? 3e4);
23978
+ timer.unref?.();
23979
+ return {
23980
+ async close() {
23981
+ clearInterval(timer);
23982
+ await current.catch(() => void 0);
23983
+ }
23984
+ };
23985
+ }
23986
+ function startHermesSessionSyncScheduler(options) {
23987
+ let running = false;
23988
+ let current = Promise.resolve();
23989
+ const syncSessions = async () => {
23990
+ if (running) {
23991
+ return;
23992
+ }
23993
+ running = true;
23994
+ try {
23995
+ await options.conversations.syncHermesSessions();
23996
+ } catch (error) {
23997
+ void options.logger.warn("hermes_session_sync_failed", {
23998
+ source: "daemon_scheduler",
23999
+ error: error instanceof Error ? error.message : String(error)
24000
+ });
24001
+ } finally {
24002
+ running = false;
24003
+ }
24004
+ };
24005
+ const timer = setInterval(() => {
24006
+ current = syncSessions();
24007
+ }, options.intervalMs ?? 10 * 60 * 1e3);
24008
+ timer.unref?.();
24009
+ return {
24010
+ async close() {
24011
+ clearInterval(timer);
24012
+ await current.catch(() => void 0);
24013
+ }
24014
+ };
24015
+ }
24016
+
24017
+ // src/daemon/service.ts
24018
+ var DEFAULT_RELAY_READY_TIMEOUT_MS = 2e3;
24019
+ var RELAY_RECONNECT_PUBLIC_ROUTE_REFRESH_MIN_INTERVAL_MS = 15 * 6e4;
24020
+ async function startLinkService(options = {}) {
24021
+ const paths = options.paths ?? resolveRuntimePaths();
24022
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
24023
+ const logger = createFileLogger({ paths, minLevel: config.logLevel });
24024
+ await logger.info("service_starting", {
24025
+ port: config.port,
24026
+ mode: identity?.link_id ? "paired" : "local-only"
24027
+ });
24028
+ const migration = await migrateLinkDatabase(paths);
24029
+ if (migration.appliedVersions.length > 0) {
24030
+ await logger.info("database_migrated", {
24031
+ database_file: migration.databaseFile,
24032
+ applied_versions: migration.appliedVersions,
24033
+ current_version: migration.currentVersion
24034
+ });
24035
+ }
24036
+ const conversations = new ConversationService(paths, logger);
24037
+ await conversations.rebuildStatisticsIndex();
24038
+ let relay = null;
24039
+ let lanIpMonitor = null;
24040
+ const loadRelayStreamBatchPolicy = async (source) => {
24041
+ const streamBatchPolicy = await fetchRelayStreamBatchPolicy(config.serverBaseUrl);
24042
+ if (!streamBatchPolicy) {
24043
+ return null;
24044
+ }
24045
+ relay?.updateStreamBatchPolicy(streamBatchPolicy);
24046
+ void logger.info("relay_stream_policy_loaded", {
24047
+ source,
24048
+ flushIntervalMs: streamBatchPolicy.flushIntervalMs,
24049
+ flushBytes: streamBatchPolicy.flushBytes
24050
+ });
24051
+ return streamBatchPolicy;
24052
+ };
24053
+ let hermesSessionSync = Promise.resolve();
24054
+ const triggerHermesSessionSync = () => {
24055
+ hermesSessionSync = Promise.resolve().then(() => conversations.syncHermesSessions()).then(() => void 0).catch((error) => {
24056
+ void logger.warn("hermes_session_sync_failed", {
24057
+ source: "service_startup",
24058
+ error: error instanceof Error ? error.message : String(error)
24059
+ });
24060
+ });
24061
+ };
24062
+ const app = await createApp({
24063
+ paths,
24064
+ logger,
24065
+ conversations,
24066
+ onPairingClaimed: async () => {
24067
+ triggerHermesSessionSync();
24068
+ void loadRelayStreamBatchPolicy("pairing_claimed");
24069
+ await options.onPairingClaimed?.();
24070
+ }
24071
+ });
24072
+ const server = createServer(app.callback());
24073
+ try {
24074
+ await listenServer(server, config.port);
24075
+ } catch (error) {
24076
+ await logger.error("service_start_failed", {
24077
+ port: config.port,
24078
+ link_id: identity?.link_id ?? null,
24079
+ error: error instanceof Error ? error.message : String(error)
24080
+ });
24081
+ await logger.flush();
24082
+ throw error;
24083
+ }
24084
+ server.on("error", (error) => {
24085
+ void logger.error("service_error", {
24086
+ port: config.port,
24087
+ link_id: identity?.link_id ?? null,
24088
+ error: error.message
24089
+ });
24090
+ });
24091
+ void logger.info("service_started", {
24092
+ port: config.port,
24093
+ link_id: identity?.link_id ?? null
24094
+ });
24095
+ triggerHermesSessionSync();
24096
+ const scheduler = startCronDeliveryScheduler({
24097
+ paths,
24098
+ conversations,
24099
+ logger
24100
+ });
24101
+ const hermesSessionSyncScheduler = startHermesSessionSyncScheduler({
24102
+ conversations,
24103
+ logger
24104
+ });
24105
+ let hasSeenRelayConnected = false;
24106
+ let lastRelayReconnectPublicRouteRefreshAt = 0;
24107
+ if (identity?.link_id) {
24108
+ let resolveRelayReady = null;
24109
+ const relayReady = new Promise((resolve) => {
24110
+ resolveRelayReady = resolve;
24111
+ });
24112
+ relay = connectRelayControl({
24113
+ relayBaseUrl: config.relayBaseUrl,
24114
+ linkId: identity.link_id,
24115
+ localPort: config.port,
24116
+ maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
24117
+ backoffBaseMs: 1e3,
24118
+ backoffMaxMs: 3e4,
24119
+ onStreamBatchPolicy: (policy) => {
24120
+ void logger.info("relay_stream_policy_updated", {
24121
+ flushIntervalMs: policy.flushIntervalMs,
24122
+ flushBytes: policy.flushBytes
24123
+ });
24124
+ },
24125
+ onStatus: (status) => {
24126
+ void logger.info("relay_status", status);
24127
+ if (status.state === "connected") {
24128
+ const now = Date.now();
24129
+ if (hasSeenRelayConnected && lanIpMonitor && now - lastRelayReconnectPublicRouteRefreshAt >= RELAY_RECONNECT_PUBLIC_ROUTE_REFRESH_MIN_INTERVAL_MS) {
24130
+ lastRelayReconnectPublicRouteRefreshAt = now;
24131
+ void lanIpMonitor.refreshPublicRoutes();
24132
+ }
24133
+ hasSeenRelayConnected = true;
24134
+ resolveRelayReady?.(true);
24135
+ resolveRelayReady = null;
24136
+ } else if (status.state === "failed") {
24137
+ resolveRelayReady?.(false);
24138
+ resolveRelayReady = null;
24139
+ }
24140
+ }
24141
+ });
24142
+ void loadRelayStreamBatchPolicy("service_startup");
24143
+ if (options.waitForRelayReady) {
24144
+ await Promise.race([
24145
+ relayReady,
24146
+ waitForRelayReadyTimeout(options.relayReadyTimeoutMs)
24147
+ ]);
24148
+ resolveRelayReady = null;
24149
+ }
24150
+ } else {
24151
+ void logger.info("relay_skipped", { reason: "link_not_paired" });
24152
+ }
24153
+ lanIpMonitor = startLanIpMonitor({
24154
+ paths,
24155
+ logger,
24156
+ intervalMs: options.lanIpMonitorIntervalMs,
24157
+ dailyReportLimit: options.lanIpMonitorDailyReportLimit,
24158
+ fetchImpl: options.lanIpMonitorFetchImpl,
24159
+ onNetworkRoutes: (routes) => {
24160
+ relay?.publishNetworkRoutes(routes);
24161
+ }
24162
+ });
24163
+ if (options.writePidFile) {
24164
+ await writePidFile(paths);
24165
+ }
24166
+ return {
24167
+ async close() {
24168
+ relay?.close();
24169
+ await closeServer(server);
24170
+ await Promise.all([
24171
+ scheduler.close(),
24172
+ hermesSessionSyncScheduler.close(),
24173
+ lanIpMonitor?.close(),
24174
+ hermesSessionSync.catch(() => void 0)
24175
+ ]);
24176
+ await logger.info("service_stopped");
24177
+ await logger.flush();
24178
+ if (options.writePidFile) {
24179
+ await rm8(pidFilePath(paths), { force: true }).catch(() => void 0);
24180
+ }
24181
+ }
24182
+ };
24183
+ }
24184
+ function waitForRelayReadyTimeout(timeoutMs) {
24185
+ return new Promise((resolve) => {
24186
+ const timer = setTimeout(
24187
+ () => resolve(false),
24188
+ timeoutMs ?? DEFAULT_RELAY_READY_TIMEOUT_MS
24189
+ );
24190
+ timer.unref?.();
24191
+ });
24192
+ }
24193
+ function pidFilePath(paths = resolveRuntimePaths()) {
24194
+ return `${paths.runDir}/hermeslink.pid`;
24195
+ }
24196
+ async function writePidFile(paths) {
24197
+ await mkdir13(paths.runDir, { recursive: true, mode: 448 });
24198
+ await writeFile3(pidFilePath(paths), `${process.pid}
24199
+ `, { mode: 384 });
24200
+ }
24201
+ async function closeServer(server) {
24202
+ await new Promise((resolve, reject) => {
24203
+ let settled = false;
24204
+ let forceCloseTimer;
24205
+ let timeoutTimer;
24206
+ const settle = (error) => {
24207
+ if (settled) {
24208
+ return;
24209
+ }
24210
+ settled = true;
24211
+ clearTimeout(forceCloseTimer);
24212
+ clearTimeout(timeoutTimer);
24213
+ if (error) {
24214
+ reject(error);
24215
+ return;
24216
+ }
24217
+ resolve();
24218
+ };
24219
+ forceCloseTimer = setTimeout(() => {
24220
+ server.closeIdleConnections?.();
24221
+ server.closeAllConnections?.();
24222
+ }, 250);
24223
+ timeoutTimer = setTimeout(() => {
24224
+ server.closeAllConnections?.();
24225
+ settle();
24226
+ }, 5e3);
24227
+ server.close((error) => {
24228
+ if (error) {
24229
+ settle(error);
24230
+ return;
24231
+ }
24232
+ settle();
24233
+ });
24234
+ server.closeIdleConnections?.();
24235
+ });
24236
+ }
24237
+ async function listenServer(server, port) {
24238
+ await new Promise((resolve, reject) => {
24239
+ const cleanup = () => {
24240
+ server.off("error", onError);
24241
+ server.off("listening", onListening);
24242
+ };
24243
+ const onError = (error) => {
24244
+ cleanup();
24245
+ reject(error);
24246
+ };
24247
+ const onListening = () => {
24248
+ cleanup();
24249
+ resolve();
24250
+ };
24251
+ server.once("error", onError);
24252
+ server.once("listening", onListening);
24253
+ server.listen(port);
24254
+ });
24255
+ }
24256
+
24257
+ // src/daemon/process.ts
24258
+ async function startDaemonProcess(paths = resolveRuntimePaths()) {
24259
+ const config = await loadConfig(paths);
24260
+ let status = await getDaemonStatus(paths);
24261
+ if (status.running) {
24262
+ const probe = await probeLocalLinkService({ port: config.port, timeoutMs: 500 });
24263
+ if (probe.reachable) {
24264
+ return status;
24265
+ }
24266
+ await stopDaemonProcess(paths);
24267
+ status = await getDaemonStatus(paths);
24268
+ if (status.running) {
24269
+ return status;
24270
+ }
24271
+ }
24272
+ await mkdir14(paths.logsDir, { recursive: true, mode: 448 });
24273
+ await mkdir14(paths.runDir, { recursive: true, mode: 448 });
24274
+ const scriptPath = currentCliScriptPath();
24275
+ const child = spawn4(process.execPath, [scriptPath, "daemon-supervisor"], {
24276
+ detached: true,
24277
+ stdio: "ignore",
24278
+ env: process.env
24279
+ });
24280
+ child.unref();
24281
+ for (let index = 0; index < 12; index += 1) {
24282
+ await wait(250);
24283
+ const next = await getDaemonStatus(paths);
24284
+ if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
24285
+ return next;
24286
+ }
24287
+ }
24288
+ return await getDaemonStatus(paths);
24289
+ }
24290
+ async function runDaemonSupervisor(paths = resolveRuntimePaths()) {
24291
+ await mkdir14(paths.logsDir, { recursive: true, mode: 448 });
24292
+ const log = createRotatingTextLogWriter({
24293
+ paths,
24294
+ fileName: path24.basename(daemonLogFile(paths))
24295
+ });
24296
+ const scriptPath = currentCliScriptPath();
24297
+ const child = spawn4(process.execPath, [scriptPath, "daemon", "--foreground"], {
24298
+ stdio: ["ignore", "pipe", "pipe"],
24299
+ env: process.env
24300
+ });
24301
+ const write = (chunk) => {
24302
+ void log.write(chunk);
24303
+ };
24304
+ write(`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor started
24305
+ `);
24306
+ child.stdout?.on("data", write);
24307
+ child.stderr?.on("data", write);
24308
+ const forwardStop = () => {
24309
+ if (child.pid && isProcessAlive3(child.pid)) {
24310
+ child.kill("SIGTERM");
24311
+ }
24312
+ };
24313
+ process.once("SIGINT", forwardStop);
24314
+ process.once("SIGTERM", forwardStop);
24315
+ const result = await new Promise((resolve, reject) => {
24316
+ child.once("error", reject);
24317
+ child.once("exit", (code, signal) => resolve({ code, signal }));
24318
+ }).catch((error) => {
24319
+ write(`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor failed: ${error instanceof Error ? error.message : String(error)}
24320
+ `);
24321
+ return { code: 1, signal: null };
24322
+ });
24323
+ process.off("SIGINT", forwardStop);
24324
+ process.off("SIGTERM", forwardStop);
24325
+ write(
24326
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor stopped code=${result.code ?? "null"} signal=${result.signal ?? "null"}
24327
+ `
24328
+ );
24329
+ await log.flush();
24330
+ return result.code ?? (result.signal ? 0 : 1);
24331
+ }
24332
+ async function probeLocalLinkService(options) {
24333
+ const unreachable = {
24334
+ reachable: false,
24335
+ reusable: false,
24336
+ linkId: null,
24337
+ version: null
24338
+ };
24339
+ let response;
24340
+ try {
24341
+ response = await fetch(`http://127.0.0.1:${options.port}/api/v1/bootstrap`, {
24342
+ headers: { accept: "application/json" },
24343
+ signal: AbortSignal.timeout(options.timeoutMs ?? 1e3)
24344
+ });
24345
+ } catch {
24346
+ return unreachable;
24347
+ }
24348
+ if (!response.ok) {
24349
+ return unreachable;
24350
+ }
24351
+ const payload = await response.json().catch(() => null);
24352
+ if (!payload || payload.api_version !== 1) {
24353
+ return unreachable;
24354
+ }
24355
+ const linkId = typeof payload.link_id === "string" ? payload.link_id : null;
24356
+ return {
24357
+ reachable: true,
24358
+ reusable: options.linkId ? linkId === options.linkId : true,
24359
+ linkId,
24360
+ version: typeof payload.version === "string" ? payload.version : null
24361
+ };
24362
+ }
24363
+ async function stopDaemonProcess(paths = resolveRuntimePaths()) {
24364
+ const status = await getDaemonStatus(paths);
24365
+ if (!status.running || !status.pid) {
24366
+ return status;
24367
+ }
24368
+ try {
24369
+ process.kill(status.pid, "SIGTERM");
24370
+ } catch {
24371
+ await rm9(pidFilePath(paths), { force: true }).catch(() => void 0);
24372
+ return await getDaemonStatus(paths);
24373
+ }
24374
+ for (let index = 0; index < 20; index += 1) {
24375
+ await wait(250);
24376
+ if (!isProcessAlive3(status.pid)) {
24377
+ break;
24378
+ }
24379
+ }
24380
+ if (isProcessAlive3(status.pid)) {
24381
+ try {
24382
+ process.kill(status.pid, "SIGKILL");
24383
+ } catch {
24384
+ }
24385
+ for (let index = 0; index < 10; index += 1) {
24386
+ await wait(250);
24387
+ if (!isProcessAlive3(status.pid)) {
24388
+ break;
24389
+ }
24390
+ }
24391
+ }
24392
+ if (!isProcessAlive3(status.pid) || !await pidBackedServiceIsReachable(paths)) {
24393
+ await rm9(pidFilePath(paths), { force: true }).catch(() => void 0);
24394
+ }
24395
+ return await getDaemonStatus(paths);
24396
+ }
24397
+ async function getDaemonStatus(paths = resolveRuntimePaths()) {
24398
+ const pidFile = pidFilePath(paths);
24399
+ const pid = await readPid(pidFile);
24400
+ if (pid && !isProcessAlive3(pid)) {
24401
+ await rm9(pidFile, { force: true }).catch(() => void 0);
24402
+ return {
24403
+ running: false,
24404
+ pid: null,
24405
+ pidFile,
24406
+ logFile: daemonLogFile(paths)
24407
+ };
24408
+ }
24409
+ return {
24410
+ running: Boolean(pid),
24411
+ pid,
24412
+ pidFile,
24413
+ logFile: daemonLogFile(paths)
24414
+ };
24415
+ }
24416
+ function daemonLogFile(paths = resolveRuntimePaths()) {
24417
+ return getDaemonLogFile(paths);
24418
+ }
24419
+ function currentCliScriptPath() {
24420
+ return process.argv[1];
24421
+ }
24422
+ async function readPid(filePath) {
24423
+ const raw = await readFile17(filePath, "utf8").catch(() => null);
24424
+ if (!raw) {
24425
+ return null;
24426
+ }
24427
+ const pid = Number.parseInt(raw.trim(), 10);
24428
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
24429
+ }
24430
+ function isProcessAlive3(pid) {
24431
+ try {
24432
+ process.kill(pid, 0);
24433
+ return true;
24434
+ } catch {
24435
+ return false;
24436
+ }
24437
+ }
24438
+ async function pidBackedServiceIsReachable(paths) {
24439
+ const config = await loadConfig(paths).catch(() => null);
24440
+ if (!config) {
24441
+ return false;
24442
+ }
24443
+ return (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable;
24444
+ }
24445
+ function wait(ms) {
24446
+ return new Promise((resolve) => setTimeout(resolve, ms));
24447
+ }
24448
+
24449
+ // src/link/updates.ts
24450
+ var SERVER_LINK_CURRENT_RELEASE_PATH = "/api/v1/link/releases/current";
24451
+ var SERVER_LINK_INSTALL_SCRIPTS_PATH = "/api/v1/link/install-scripts";
24452
+ var LINK_NPM_PACKAGE = "@hermespilot/link";
24453
+ var OFFICIAL_INSTALLER_BASE_URL = "https://hs.clawpilot.me/install";
24454
+ var OFFICIAL_UNIX_INSTALLER_URL = `${OFFICIAL_INSTALLER_BASE_URL}/install.sh`;
24455
+ var OFFICIAL_WINDOWS_INSTALLER_URL = `${OFFICIAL_INSTALLER_BASE_URL}/install.ps1`;
24456
+ var UPDATE_LOG_FILE2 = "link-update.log";
24457
+ var UPDATE_LOG_MAX_FILES2 = 3;
24458
+ var UPDATE_FETCH_TIMEOUT_MS = 5e3;
24459
+ var MAX_UPDATE_LOG_LINES2 = 240;
24460
+ var MAX_OUTPUT_LINE_LENGTH3 = 1200;
24461
+ var AUTO_RESTART_DELAY_MS = 1500;
24462
+ var RUNNING_STATE_GRACE_MS = 1e4;
24463
+ var LINK_UPDATE_TIMEOUT_MS = 15 * 60 * 1e3;
24464
+ var updateEvents2 = new EventEmitter4();
24465
+ var runningUpdate2 = null;
24466
+ async function readLinkUpdateCheck(options) {
24467
+ const remoteResult = await readRemoteLinkPolicy(options);
24468
+ const remote = remoteResult.remote;
24469
+ const state = computeLinkUpdateState(LINK_VERSION, remote);
24470
+ const targetVersion = remote?.target_version ?? null;
24471
+ return {
24472
+ ok: true,
24473
+ local: {
24474
+ version: LINK_VERSION,
24475
+ raw: LINK_VERSION
24476
+ },
24477
+ remote,
24478
+ state,
24479
+ update_available: state === "update_available" || state === "unsafe" || state === "blocked",
24480
+ unsafe: state === "unsafe",
24481
+ blocked: state === "blocked",
24482
+ check_state: remoteResult.state,
24483
+ issue: remoteResult.issue,
24484
+ manual: {
24485
+ command: targetVersion ? manualInstallCommand(targetVersion) : null,
24486
+ package: LINK_NPM_PACKAGE,
24487
+ version: targetVersion
24488
+ }
24489
+ };
24490
+ }
24491
+ async function startLinkUpdate(options) {
24492
+ const current = await readLinkUpdateStatus(options.paths);
24493
+ if (runningUpdate2 || current.state === "running") {
24494
+ return current;
24495
+ }
24496
+ const check = await readLinkUpdateCheck(options);
24497
+ const targetVersion = check.remote?.target_version ?? null;
24498
+ if (!targetVersion) {
24499
+ return writeFailedStartState(
24500
+ options,
24501
+ "HermesPilot Server has no Link target version."
24502
+ );
24503
+ }
24504
+ if (!isValidReleaseVersion(targetVersion)) {
24505
+ return writeFailedStartState(
24506
+ options,
24507
+ `HermesPilot Server returned invalid Link target version: ${targetVersion}.`,
24508
+ targetVersion
24509
+ );
24510
+ }
24511
+ if (options.targetVersion && options.targetVersion !== targetVersion) {
24512
+ return writeFailedStartState(
24513
+ options,
24514
+ `Requested target ${options.targetVersion} does not match current Link target ${targetVersion}.`,
24515
+ targetVersion
24516
+ );
24517
+ }
24518
+ if (check.state === "current") {
24519
+ return writeFailedStartState(
24520
+ options,
24521
+ "Hermes Link is already on the current version.",
24522
+ targetVersion
24523
+ );
24524
+ }
24525
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
24526
+ const jobId = `link_update_${now().getTime().toString(36)}`;
24527
+ await clearUpdateLogFiles2(options.paths);
24528
+ const writer = createRotatingTextLogWriter({
24529
+ paths: options.paths,
24530
+ fileName: UPDATE_LOG_FILE2,
24531
+ maxFileBytes: 512 * 1024,
24532
+ maxFiles: UPDATE_LOG_MAX_FILES2
24533
+ });
24534
+ const startedAt = now().toISOString();
24535
+ const installCommand = await buildOfficialInstallCommand(options, targetVersion);
24536
+ const manualCommand = installCommand.displayCommand;
24537
+ const started = {
24538
+ state: "running",
24539
+ job_id: jobId,
24540
+ pid: null,
24541
+ target_version: targetVersion,
24542
+ started_at: startedAt,
24543
+ finished_at: null,
24544
+ exit_code: null,
24545
+ signal: null,
24546
+ error: null,
24547
+ manual_command: manualCommand
24548
+ };
24549
+ await mkdir15(options.paths.runDir, { recursive: true, mode: 448 });
24550
+ await writer.write(
24551
+ `
24552
+ === link update started ${startedAt} target=${targetVersion} ===
24553
+ `
24554
+ );
24555
+ await writer.write(`$ ${manualCommand}
24556
+ `);
24557
+ await writeUpdateState2(options.paths, started);
24558
+ if (process.platform === "win32") {
24559
+ await writer.write(
24560
+ "[windows-updater] A detached updater will stop Hermes Link before replacing the npm package, then start it again.\n"
24561
+ );
24562
+ await writer.flush();
24563
+ const child2 = spawnWindowsDetachedUpdater({
24564
+ installCommand,
24565
+ statePath: updateStatePath2(options.paths),
24566
+ logPath: writer.filePath,
24567
+ jobId,
24568
+ targetVersion,
24569
+ startedAt,
24570
+ manualCommand
24571
+ });
24572
+ started.pid = child2.pid ?? null;
24573
+ await writeUpdateState2(options.paths, started);
24574
+ child2.on("error", (error) => {
24575
+ void (async () => {
24576
+ const failed = {
24577
+ ...started,
24578
+ state: "failed",
24579
+ finished_at: now().toISOString(),
24580
+ error: error.message
24581
+ };
24582
+ await writer.write(
24583
+ `
24584
+ [failed] Windows detached updater failed to start: ${error.message}
24585
+ `
24586
+ );
24587
+ await writeUpdateState2(options.paths, failed);
24588
+ await emitUpdateStatus2(options.paths);
24589
+ })();
24590
+ });
24591
+ await emitUpdateStatus2(options.paths);
24592
+ void options.logger?.info("link_update_started", {
24593
+ job_id: jobId,
24594
+ pid: child2.pid ?? null,
24595
+ target_version: targetVersion,
24596
+ log_path: writer.filePath,
24597
+ strategy: "windows_detached_updater"
24598
+ });
24599
+ return readLinkUpdateStatus(options.paths);
24600
+ }
24601
+ const child = spawnInstallCommand(installCommand);
24602
+ started.pid = child.pid ?? null;
24603
+ await writeUpdateState2(options.paths, started);
24604
+ const appendChunk = async (chunk) => {
24605
+ await writer.write(chunk);
24606
+ await emitUpdateStatus2(options.paths);
24607
+ };
24608
+ child.stdout?.on("data", (chunk) => {
24609
+ void appendChunk(chunk);
24610
+ });
24611
+ child.stderr?.on("data", (chunk) => {
24612
+ void appendChunk(chunk);
24613
+ });
24614
+ let timedOut = false;
24615
+ const timeoutTimer = setTimeout(() => {
24616
+ timedOut = true;
24617
+ void (async () => {
24618
+ const failed = {
24619
+ ...started,
24620
+ state: "failed",
24621
+ finished_at: now().toISOString(),
24622
+ error: linkUpdateTimeoutError()
24623
+ };
24624
+ await writer.write(`
24625
+ [failed] ${failed.error}
24626
+ `);
24627
+ await writeUpdateState2(options.paths, failed);
24628
+ await emitUpdateStatus2(options.paths);
24629
+ if (!child.killed) {
24630
+ child.kill("SIGTERM");
24631
+ }
24632
+ void options.logger?.error("link_update_timed_out", {
24633
+ job_id: jobId,
24634
+ target_version: targetVersion,
24635
+ timeout_ms: LINK_UPDATE_TIMEOUT_MS
24636
+ });
24637
+ })();
24638
+ }, LINK_UPDATE_TIMEOUT_MS);
24639
+ timeoutTimer.unref();
24640
+ runningUpdate2 = new Promise((resolve) => {
24641
+ child.on("error", (error) => {
24642
+ void (async () => {
24643
+ clearTimeout(timeoutTimer);
24644
+ const failed = {
24645
+ ...started,
24646
+ state: "failed",
24647
+ finished_at: now().toISOString(),
24648
+ error: error.message
24649
+ };
24650
+ await writer.write(
24651
+ `
24652
+ [failed] link update failed to start: ${error.message}
24653
+ `
24654
+ );
24655
+ await writeUpdateState2(options.paths, failed);
24656
+ await emitUpdateStatus2(options.paths);
24657
+ void options.logger?.error("link_update_spawn_failed", {
24658
+ job_id: jobId,
24659
+ target_version: targetVersion,
24660
+ error: error.message
24661
+ });
24662
+ resolve(await readLinkUpdateStatus(options.paths));
24663
+ })();
24664
+ });
24665
+ child.on("close", (code, signal) => {
24666
+ void (async () => {
24667
+ clearTimeout(timeoutTimer);
24668
+ const succeeded = code === 0;
24669
+ const state = {
24670
+ ...started,
24671
+ state: succeeded ? "restart_required" : "failed",
24672
+ finished_at: now().toISOString(),
24673
+ exit_code: code,
24674
+ signal,
24675
+ error: succeeded ? null : timedOut ? linkUpdateTimeoutError() : `install script exited with code ${code ?? "unknown"}`
24676
+ };
24677
+ await writer.write(
24678
+ `
24679
+ === link update finished ${state.finished_at} exit=${code ?? "null"} signal=${signal ?? "null"} ===
24680
+ `
24681
+ );
24682
+ if (succeeded) {
24683
+ await writer.write(
24684
+ `
24685
+ [restart-scheduled] Hermes Link will restart automatically. If it does not reconnect, run \`${LINK_COMMAND} restart\` on this computer.
24686
+ `
24687
+ );
24688
+ }
24689
+ await writeUpdateState2(options.paths, state);
24690
+ await emitUpdateStatus2(options.paths);
24691
+ if (succeeded) {
24692
+ await writer.flush();
24693
+ scheduleAutomaticRestart(options);
24694
+ }
24695
+ void options.logger?.info(
24696
+ succeeded ? "link_update_restart_required" : "link_update_failed",
24697
+ {
24698
+ job_id: jobId,
24699
+ target_version: targetVersion,
24700
+ exit_code: code,
24701
+ signal: signal ?? null
24702
+ }
24703
+ );
24704
+ resolve(await readLinkUpdateStatus(options.paths));
24705
+ })();
24706
+ });
24707
+ }).finally(() => {
24708
+ runningUpdate2 = null;
24709
+ });
24710
+ await emitUpdateStatus2(options.paths);
24711
+ void options.logger?.info("link_update_started", {
24712
+ job_id: jobId,
24713
+ pid: child.pid ?? null,
24714
+ target_version: targetVersion,
24715
+ log_path: writer.filePath
24716
+ });
24717
+ return readLinkUpdateStatus(options.paths);
24718
+ }
24719
+ function scheduleAutomaticRestart(options) {
24720
+ const scriptPath = currentCliScriptPath();
24721
+ setTimeout(() => {
24722
+ const child = spawn5(process.execPath, [scriptPath, "restart"], {
24723
+ detached: true,
24724
+ stdio: "ignore",
24725
+ env: process.env,
24726
+ windowsHide: true
24727
+ });
24728
+ child.unref();
24729
+ void options.logger?.info("link_update_restart_scheduled", {
24730
+ delay_ms: AUTO_RESTART_DELAY_MS,
24731
+ command: `${LINK_COMMAND} restart`
24732
+ });
24733
+ }, AUTO_RESTART_DELAY_MS).unref();
24734
+ }
24735
+ async function readLinkUpdateStatus(paths) {
24736
+ let state = await readJsonFile(updateStatePath2(paths));
24737
+ if ((state?.state === "running" || state?.state === "restart_required") && state.target_version && compareSemver3(LINK_VERSION, state.target_version) >= 0) {
24738
+ state = {
24739
+ ...state,
24740
+ state: "succeeded",
24741
+ finished_at: state.finished_at ?? (/* @__PURE__ */ new Date()).toISOString(),
24742
+ error: null
24743
+ };
24744
+ await writeUpdateState2(paths, state);
24745
+ }
24746
+ if (state?.state === "running" && isRunningStateTimedOut(state)) {
24747
+ const reachedTarget = state.target_version && compareSemver3(LINK_VERSION, state.target_version) >= 0;
24748
+ state = reachedTarget ? {
24749
+ ...state,
24750
+ state: "succeeded",
24751
+ finished_at: (/* @__PURE__ */ new Date()).toISOString(),
24752
+ error: null
24753
+ } : {
24754
+ ...state,
24755
+ state: "failed",
24756
+ finished_at: (/* @__PURE__ */ new Date()).toISOString(),
24757
+ error: state.error ?? linkUpdateTimeoutError()
24758
+ };
24759
+ await writeUpdateState2(paths, state);
24760
+ }
24761
+ if (state?.state === "running" && !runningUpdate2 && !isRecentRunningState3(state) && !isProcessAlive4(state.pid)) {
24762
+ const reachedTarget = state.target_version && compareSemver3(LINK_VERSION, state.target_version) >= 0;
24763
+ state = reachedTarget ? {
24764
+ ...state,
24765
+ state: "succeeded",
24766
+ finished_at: (/* @__PURE__ */ new Date()).toISOString(),
24767
+ error: null
24768
+ } : {
24769
+ ...state,
24770
+ state: "failed",
24771
+ finished_at: (/* @__PURE__ */ new Date()).toISOString(),
24772
+ error: state.error ?? "Link update was interrupted before Hermes Link could observe completion."
24773
+ };
24774
+ await writeUpdateState2(paths, state);
24775
+ }
24776
+ return {
24777
+ ok: true,
24778
+ state: state?.state ?? "idle",
24779
+ job_id: state?.job_id ?? null,
24780
+ pid: state?.pid ?? null,
24781
+ target_version: state?.target_version ?? null,
24782
+ started_at: state?.started_at ?? null,
24783
+ finished_at: state?.finished_at ?? null,
24784
+ exit_code: state?.exit_code ?? null,
24785
+ signal: state?.signal ?? null,
24786
+ log_path: updateLogPath2(paths),
24787
+ lines: await readUpdateLogLines2(paths),
24788
+ error: state?.error ?? null,
24789
+ manual_command: state?.manual_command ?? null
24790
+ };
24791
+ }
24792
+ function subscribeLinkUpdateStatus(listener) {
24793
+ updateEvents2.on("status", listener);
24794
+ return () => updateEvents2.off("status", listener);
24795
+ }
24796
+ async function writeFailedStartState(options, error, targetVersion = null) {
24797
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
24798
+ const state = {
24799
+ state: "failed",
24800
+ job_id: `link_update_${now().getTime().toString(36)}`,
24801
+ pid: null,
24802
+ target_version: targetVersion,
24803
+ started_at: now().toISOString(),
24804
+ finished_at: now().toISOString(),
24805
+ exit_code: null,
24806
+ signal: null,
24807
+ error,
24808
+ manual_command: targetVersion ? manualInstallCommand(targetVersion) : null
24809
+ };
24810
+ await writeUpdateState2(options.paths, state);
24811
+ await emitUpdateStatus2(options.paths);
24812
+ return readLinkUpdateStatus(options.paths);
24813
+ }
24814
+ async function readRemoteLinkPolicy(options) {
24815
+ const context = await readLinkReleaseCheckContext(options.paths).catch(
24816
+ () => null
24817
+ );
24818
+ try {
24819
+ const response = await fetchCurrentLinkReleaseFromServer(
24820
+ options,
24821
+ options.fetchImpl ?? fetch
24822
+ );
24823
+ if (!response.ok) {
24824
+ throw new Error(`HermesPilot Server returned HTTP ${response.status}`);
24825
+ }
24826
+ const snapshot = normalizeServerSnapshot(await response.json());
24827
+ if (!snapshot.remote) {
24828
+ return {
24829
+ remote: null,
24830
+ state: "unavailable",
24831
+ issue: snapshot.issue ?? "HermesPilot Server has no Link release policy"
24832
+ };
24833
+ }
24834
+ return {
24835
+ remote: snapshot.remote,
24836
+ state: "fresh",
24837
+ issue: snapshot.issue
24838
+ };
24839
+ } catch (error) {
24840
+ const issue = error instanceof Error ? error.message : String(error);
24841
+ void options.logger?.warn("link_release_server_check_failed", {
24842
+ server_base_url: context?.serverBaseUrl ?? null,
24843
+ release_check_url: context?.releaseCheckUrl ?? null,
24844
+ error: issue
24845
+ });
24846
+ return { remote: null, state: "unavailable", issue };
23615
24847
  }
23616
- return `${os4.type()} ${os4.release()}`.trim();
23617
24848
  }
23618
- function readLinuxOsRelease() {
23619
- for (const file of ["/etc/os-release", "/usr/lib/os-release"]) {
23620
- try {
23621
- return parseLinuxOsRelease(readFileSync(file, "utf8"));
23622
- } catch {
23623
- }
24849
+ function normalizeServerSnapshot(payload) {
24850
+ const snapshot = toRecord17(payload);
24851
+ const policy = toNullableRecord2(snapshot.policy);
24852
+ if (!policy) {
24853
+ return {
24854
+ remote: null,
24855
+ issue: readString18(snapshot, "issue")
24856
+ };
23624
24857
  }
23625
- return null;
24858
+ const release = toNullableRecord2(snapshot.release);
24859
+ const currentVersion = readString18(policy, "current_version") ?? readString18(policy, "currentVersion");
24860
+ const minSafeVersion = readString18(policy, "min_safe_version") ?? readString18(policy, "minSafeVersion");
24861
+ if (!currentVersion) {
24862
+ return {
24863
+ remote: null,
24864
+ issue: readString18(snapshot, "issue")
24865
+ };
24866
+ }
24867
+ return {
24868
+ remote: {
24869
+ current_version: currentVersion,
24870
+ min_safe_version: minSafeVersion,
24871
+ target_version: currentVersion,
24872
+ release_url: release ? readString18(release, "release_url") ?? readString18(release, "releaseUrl") : null,
24873
+ published_at: release ? readString18(release, "published_at") ?? readString18(release, "publishedAt") : null
24874
+ },
24875
+ issue: readString18(snapshot, "issue")
24876
+ };
23626
24877
  }
23627
- function readCommandOutput(command, args) {
24878
+ async function fetchCurrentLinkReleaseFromServer(options, fetcher) {
24879
+ const config = await loadConfig(options.paths);
24880
+ const url = new URL(SERVER_LINK_CURRENT_RELEASE_PATH, config.serverBaseUrl);
24881
+ url.searchParams.set("channel", "stable");
24882
+ url.searchParams.set("lang", "en");
24883
+ const controller = new AbortController();
24884
+ const timer = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS);
23628
24885
  try {
23629
- const output = execFileSync(command, args, {
23630
- encoding: "utf8",
23631
- stdio: ["ignore", "pipe", "ignore"],
23632
- timeout: 1e3
24886
+ return await fetcher(url, {
24887
+ headers: {
24888
+ accept: "application/json",
24889
+ "user-agent": `HermesPilot-Link/${LINK_VERSION}`
24890
+ },
24891
+ signal: controller.signal
23633
24892
  });
23634
- return normalizeText(output);
23635
- } catch {
23636
- return null;
23637
- }
23638
- }
23639
- function buildLinuxName(values) {
23640
- const name = normalizeText(values.get("NAME"));
23641
- const version = normalizeText(values.get("VERSION_ID")) ?? normalizeText(values.get("VERSION"));
23642
- if (name && version) {
23643
- return `${name} ${version}`;
23644
- }
23645
- return name ?? version;
23646
- }
23647
- function unquoteOsReleaseValue(value) {
23648
- const trimmed = value.trim();
23649
- if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
23650
- return trimmed.slice(1, -1).replace(/\\(["'`$\\])/gu, "$1");
24893
+ } catch (error) {
24894
+ if (error instanceof Error && error.name === "AbortError") {
24895
+ throw new Error("HermesPilot Server Link release check timed out");
24896
+ }
24897
+ throw error;
24898
+ } finally {
24899
+ clearTimeout(timer);
23651
24900
  }
23652
- return trimmed;
23653
- }
23654
- function normalizeText(value) {
23655
- const normalized = value?.replace(/\s+/gu, " ").trim();
23656
- return normalized ? normalized : null;
23657
- }
23658
- function truncateText(value, maxLength) {
23659
- return value.length > maxLength ? value.slice(0, maxLength).trimEnd() : value;
23660
24901
  }
23661
-
23662
- // src/topology/network.ts
23663
- import os6 from "os";
23664
-
23665
- // src/topology/environment.ts
23666
- import { existsSync, readFileSync as readFileSync2 } from "fs";
23667
- import os5 from "os";
23668
- function detectRuntimeEnvironment(env = process.env) {
23669
- if (isWsl(env)) {
23670
- return {
23671
- kind: "wsl",
23672
- lanAutoDiscoveryUsable: false,
23673
- warning: "Detected WSL. The LAN IP found inside WSL is usually a private VM address and is not reachable from your phone. Use Relay or set `hermeslink config set lan-host <Windows LAN IP>`."
23674
- };
23675
- }
23676
- if (isContainer(env)) {
24902
+ async function buildOfficialInstallCommand(options, targetVersion) {
24903
+ const installer = await readOfficialInstallerUrls(options).catch((error) => {
24904
+ options.logger?.warn?.(
24905
+ `[link-update] failed to read installer config from server: ${error instanceof Error ? error.message : String(error)}`
24906
+ );
24907
+ return defaultInstallerUrls();
24908
+ });
24909
+ const env = {
24910
+ HERMESLINK_VERSION: targetVersion,
24911
+ HERMESLINK_YES: "1",
24912
+ HERMESLINK_NO_PROFILE_EDIT: "1",
24913
+ HERMESLINK_NO_PATH_PROMPT: "1",
24914
+ HERMESLINK_SKIP_RESTART: "1"
24915
+ };
24916
+ if (process.platform === "win32") {
24917
+ const windowsCommand = `& { $ErrorActionPreference = "Stop"; Invoke-RestMethod ${quotePowerShellString(installer.windowsUrl)} | Invoke-Expression }`;
23677
24918
  return {
23678
- kind: "container",
23679
- lanAutoDiscoveryUsable: false,
23680
- warning: "Detected a container environment. Container LAN IPs are usually not reachable from your phone. Use Relay or set `hermeslink config set lan-host <host LAN IP>`."
24919
+ command: windowsCommand,
24920
+ displayCommand: `$env:HERMESLINK_VERSION="${targetVersion}"; $env:HERMESLINK_YES="1"; $env:HERMESLINK_NO_PATH_PROMPT="1"; $env:HERMESLINK_SKIP_RESTART="1"; ${windowsCommand}; hermeslink restart`,
24921
+ env,
24922
+ source: "official-installer",
24923
+ installerUrl: installer.windowsUrl
23681
24924
  };
23682
24925
  }
24926
+ const unixCommand = buildUnixInstallCommand(installer.unixUrl);
23683
24927
  return {
23684
- kind: "native",
23685
- lanAutoDiscoveryUsable: true,
23686
- warning: null
24928
+ command: unixCommand,
24929
+ displayCommand: `HERMESLINK_VERSION=${targetVersion} HERMESLINK_YES=1 HERMESLINK_NO_PROFILE_EDIT=1 HERMESLINK_NO_PATH_PROMPT=1 HERMESLINK_SKIP_RESTART=1 sh -c ${quoteShellToken(unixCommand)} && hermeslink restart`,
24930
+ env,
24931
+ source: "official-installer",
24932
+ installerUrl: installer.unixUrl
23687
24933
  };
23688
24934
  }
23689
- function isWsl(env) {
23690
- if (process.platform !== "linux") {
23691
- return false;
24935
+ function buildUnixInstallCommand(installerUrl) {
24936
+ const fetchScript = [
24937
+ quoteShellToken(process.execPath),
24938
+ "--input-type=module",
24939
+ "-e",
24940
+ quoteShellToken(
24941
+ "const url = process.env.HERMESLINK_INSTALLER_URL; const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); process.stdout.write(await response.text());"
24942
+ )
24943
+ ].join(" ");
24944
+ return [
24945
+ "set -e;",
24946
+ 'tmp="${TMPDIR:-/tmp}/hermespilot-link-install.$$.sh";',
24947
+ `trap 'rm -f "$tmp"' EXIT;`,
24948
+ "umask 077;",
24949
+ "if command -v curl >/dev/null 2>&1; then",
24950
+ `curl -fsSL ${quoteShellToken(installerUrl)} -o "$tmp";`,
24951
+ "else",
24952
+ `HERMESLINK_INSTALLER_URL=${quoteShellToken(installerUrl)} ${fetchScript} > "$tmp";`,
24953
+ "fi",
24954
+ 'bash "$tmp"'
24955
+ ].join(" ");
24956
+ }
24957
+ async function readOfficialInstallerUrls(options) {
24958
+ const config = await loadConfig(options.paths);
24959
+ const url = new URL(SERVER_LINK_INSTALL_SCRIPTS_PATH, config.serverBaseUrl);
24960
+ const response = await fetchInstallScriptsFromServer(
24961
+ options.fetchImpl ?? fetch,
24962
+ url
24963
+ );
24964
+ if (!response.ok) {
24965
+ throw new Error(`HermesPilot Server returned HTTP ${response.status}`);
23692
24966
  }
23693
- if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
23694
- return true;
24967
+ const snapshot = await response.json();
24968
+ const commands = snapshot.commands;
24969
+ const unixUrl = readInstallerUrl(commands?.unix, "install.sh");
24970
+ const windowsUrl = readInstallerUrl(commands?.windows, "install.ps1");
24971
+ if (!unixUrl || !windowsUrl) {
24972
+ throw new Error("HermesPilot Server did not return official installer URLs");
23695
24973
  }
23696
- const release = os5.release().toLowerCase();
23697
- return release.includes("microsoft") || release.includes("wsl");
24974
+ return {
24975
+ unixUrl,
24976
+ windowsUrl
24977
+ };
23698
24978
  }
23699
- function isContainer(env) {
23700
- if (env.container || env.CONTAINER || env.KUBERNETES_SERVICE_HOST) {
23701
- return true;
24979
+ function defaultInstallerUrls() {
24980
+ return {
24981
+ unixUrl: OFFICIAL_UNIX_INSTALLER_URL,
24982
+ windowsUrl: OFFICIAL_WINDOWS_INSTALLER_URL
24983
+ };
24984
+ }
24985
+ function readInstallerUrl(value, expectedFileName) {
24986
+ if (typeof value !== "string") {
24987
+ return null;
23702
24988
  }
23703
- if (existsSync("/.dockerenv")) {
23704
- return true;
24989
+ const match = /https:\/\/[^\s'"|]+/u.exec(value);
24990
+ if (!match) {
24991
+ return null;
24992
+ }
24993
+ const url = match[0];
24994
+ if (!isOfficialInstallerUrl(url, expectedFileName)) {
24995
+ return null;
23705
24996
  }
24997
+ return url;
24998
+ }
24999
+ function isOfficialInstallerUrl(url, expectedFileName) {
23706
25000
  try {
23707
- const cgroup = readFileSync2("/proc/1/cgroup", "utf8").toLowerCase();
23708
- return /docker|containerd|kubepods|libpod|podman/u.test(cgroup);
25001
+ const parsed = new URL(url);
25002
+ if (parsed.protocol !== "https:") {
25003
+ return false;
25004
+ }
25005
+ return parsed.hostname === "hs.clawpilot.me" && parsed.pathname === `/install/${expectedFileName}`;
23709
25006
  } catch {
23710
25007
  return false;
23711
25008
  }
23712
25009
  }
23713
-
23714
- // src/topology/network.ts
23715
- var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
23716
- var MAX_LAN_IPS = 4;
23717
- var MAX_PUBLIC_IPV4S = 2;
23718
- var MAX_PUBLIC_IPV6S = 2;
23719
- async function discoverRouteCandidates(options) {
23720
- const environment = detectRuntimeEnvironment();
23721
- const configuredLanHost = normalizeLanHost(options.configuredLanHost);
23722
- const lanIps = configuredLanHost ? [configuredLanHost] : environment.lanAutoDiscoveryUsable ? discoverLanIps() : [];
23723
- const publicIps = options.relayBootstrapToken || options.observePublicRoute ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
23724
- const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
23725
- const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
23726
- const preferredUrls = [
23727
- ...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
23728
- ...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
23729
- ...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
23730
- `${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
23731
- ];
23732
- return {
23733
- lanIps,
23734
- publicIpv4s,
23735
- publicIpv6s,
23736
- preferredUrls,
23737
- environment
23738
- };
23739
- }
23740
- function discoverLanIps() {
23741
- return discoverLanIpsFromInterfaces(os6.networkInterfaces());
23742
- }
23743
- function discoverLanIpsFromInterfaces(interfaces) {
23744
- const result = /* @__PURE__ */ new Set();
23745
- const candidates = [];
23746
- for (const [name, items] of Object.entries(interfaces)) {
23747
- if (shouldIgnoreInterface(name)) {
23748
- continue;
23749
- }
23750
- for (const item of items ?? []) {
23751
- if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv42(item.address, item.netmask)) {
23752
- candidates.push({ name, address: item.address });
23753
- }
25010
+ async function fetchInstallScriptsFromServer(fetcher, url) {
25011
+ const controller = new AbortController();
25012
+ const timer = setTimeout(() => controller.abort(), UPDATE_FETCH_TIMEOUT_MS);
25013
+ try {
25014
+ return await fetcher(url, {
25015
+ headers: {
25016
+ accept: "application/json",
25017
+ "user-agent": `HermesPilot-Link/${LINK_VERSION}`
25018
+ },
25019
+ signal: controller.signal
25020
+ });
25021
+ } catch (error) {
25022
+ if (error instanceof Error && error.name === "AbortError") {
25023
+ throw new Error("HermesPilot Server installer config check timed out");
23754
25024
  }
25025
+ throw error;
25026
+ } finally {
25027
+ clearTimeout(timer);
23755
25028
  }
23756
- for (const candidate of candidates.sort(compareLanCandidate)) {
23757
- result.add(candidate.address);
23758
- }
23759
- return [...result].slice(0, MAX_LAN_IPS);
23760
25029
  }
23761
- async function observePublicRoute(options) {
23762
- const fetcher = options.fetchImpl ?? fetch;
23763
- const response = await fetcher(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`, {
23764
- method: "POST",
23765
- headers: {
23766
- "content-type": "application/json",
23767
- ...options.relayBootstrapToken ? { authorization: `Bearer ${options.relayBootstrapToken}` } : {}
23768
- },
23769
- body: JSON.stringify({
23770
- install_id: options.installId,
23771
- link_id: options.linkId,
23772
- public_key_pem: options.publicKeyPem
23773
- })
25030
+ function spawnWindowsDetachedUpdater(input) {
25031
+ const child = spawn5(
25032
+ "powershell.exe",
25033
+ [
25034
+ "-NoProfile",
25035
+ "-ExecutionPolicy",
25036
+ "Bypass",
25037
+ "-Command",
25038
+ buildWindowsDetachedUpdaterScript(input)
25039
+ ],
25040
+ {
25041
+ detached: true,
25042
+ stdio: "ignore",
25043
+ cwd: process.env.SystemRoot ?? process.env.TEMP ?? process.cwd(),
25044
+ env: {
25045
+ ...process.env,
25046
+ ...input.installCommand.env
25047
+ },
25048
+ windowsHide: true,
25049
+ shell: false
25050
+ }
25051
+ );
25052
+ child.unref();
25053
+ return child;
25054
+ }
25055
+ function buildWindowsDetachedUpdaterScript(input) {
25056
+ return [
25057
+ '$ErrorActionPreference = "Stop"',
25058
+ "$Utf8NoBom = New-Object System.Text.UTF8Encoding -ArgumentList $false",
25059
+ `$NodePath = ${quotePowerShellString(process.execPath)}`,
25060
+ `$CliScriptPath = ${quotePowerShellString(currentCliScriptPath())}`,
25061
+ `$InstallerUrl = ${quotePowerShellString(input.installCommand.installerUrl)}`,
25062
+ `$StatePath = ${quotePowerShellString(input.statePath)}`,
25063
+ `$LogPath = ${quotePowerShellString(input.logPath)}`,
25064
+ `$JobId = ${quotePowerShellString(input.jobId)}`,
25065
+ `$TargetVersion = ${quotePowerShellString(input.targetVersion)}`,
25066
+ `$StartedAt = ${quotePowerShellString(input.startedAt)}`,
25067
+ `$ManualCommand = ${quotePowerShellString(input.manualCommand)}`,
25068
+ "$InstallerPath = $null",
25069
+ "function Ensure-ParentDirectory { param([string]$PathValue) $parent = Split-Path -Parent $PathValue; if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } }",
25070
+ "function Add-UpdateLog { param([string]$Message) Ensure-ParentDirectory $LogPath; [System.IO.File]::AppendAllText($LogPath, $Message + [Environment]::NewLine, $Utf8NoBom) }",
25071
+ "function Write-UpdateState {",
25072
+ " param([string]$State, $ExitCode, $ErrorText)",
25073
+ " Ensure-ParentDirectory $StatePath",
25074
+ " $finishedAt = $null",
25075
+ ' if ($State -ne "running") { $finishedAt = (Get-Date).ToUniversalTime().ToString("o") }',
25076
+ " $payload = [ordered]@{",
25077
+ " state = $State",
25078
+ " job_id = $JobId",
25079
+ " pid = $PID",
25080
+ " target_version = $TargetVersion",
25081
+ " started_at = $StartedAt",
25082
+ " finished_at = $finishedAt",
25083
+ " exit_code = $ExitCode",
25084
+ " signal = $null",
25085
+ " error = $ErrorText",
25086
+ " manual_command = $ManualCommand",
25087
+ " }",
25088
+ " $json = $payload | ConvertTo-Json -Compress",
25089
+ " [System.IO.File]::WriteAllText($StatePath, $json, $Utf8NoBom)",
25090
+ "}",
25091
+ "function Invoke-Step {",
25092
+ " param([string]$Label, [scriptblock]$Block, [switch]$AllowFailure)",
25093
+ ' Add-UpdateLog ""',
25094
+ ' Add-UpdateLog "=> $Label"',
25095
+ " & $Block *>> $LogPath",
25096
+ " $code = if ($null -eq $LASTEXITCODE) { 0 } else { [int]$LASTEXITCODE }",
25097
+ ' if ($code -ne 0 -and -not $AllowFailure) { throw "$Label exited with code $code" }',
25098
+ " return $code",
25099
+ "}",
25100
+ "try {",
25101
+ ' Write-UpdateState "running" $null $null',
25102
+ " Start-Sleep -Milliseconds 1500",
25103
+ ' Invoke-Step "Stopping Hermes Link before Windows package replacement" { & $NodePath $CliScriptPath stop } -AllowFailure | Out-Null',
25104
+ ' $InstallerPath = Join-Path ([System.IO.Path]::GetTempPath()) ("hermespilot-link-install-" + [Guid]::NewGuid().ToString("N") + ".ps1")',
25105
+ ' Add-UpdateLog ""',
25106
+ ' Add-UpdateLog "=> Downloading official installer"',
25107
+ ' Invoke-RestMethod -Uri $InstallerUrl -Headers @{ "User-Agent" = "HermesPilot-Link-Updater" } -OutFile $InstallerPath',
25108
+ " $env:HERMESLINK_VERSION = $TargetVersion",
25109
+ ' $env:HERMESLINK_YES = "1"',
25110
+ ' $env:HERMESLINK_NO_PATH_PROMPT = "1"',
25111
+ ' $env:HERMESLINK_SKIP_RESTART = "1"',
25112
+ ' Invoke-Step "Installing Hermes Link $TargetVersion" { & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $InstallerPath -Version $TargetVersion -NoPathPrompt -SkipRestart } | Out-Null',
25113
+ ' Invoke-Step "Starting Hermes Link after update" { & $NodePath $CliScriptPath start } | Out-Null',
25114
+ ' Add-UpdateLog ""',
25115
+ ' Add-UpdateLog ("=== link update finished " + (Get-Date).ToUniversalTime().ToString("o") + " exit=0 signal=null ===")',
25116
+ ' Write-UpdateState "restart_required" 0 $null',
25117
+ " exit 0",
25118
+ "} catch {",
25119
+ " $message = if ($_.Exception) { $_.Exception.Message } else { [string]$_ }",
25120
+ ' Add-UpdateLog ""',
25121
+ ' Add-UpdateLog "[failed] $message"',
25122
+ " try { & $NodePath $CliScriptPath start *>> $LogPath } catch {}",
25123
+ ' Write-UpdateState "failed" 1 $message',
25124
+ " exit 1",
25125
+ "} finally {",
25126
+ " if ($InstallerPath -and (Test-Path -LiteralPath $InstallerPath)) { Remove-Item -LiteralPath $InstallerPath -Force -ErrorAction SilentlyContinue }",
25127
+ "}"
25128
+ ].join("\n");
25129
+ }
25130
+ function spawnInstallCommand(input) {
25131
+ const env = {
25132
+ ...process.env,
25133
+ ...input.env
25134
+ };
25135
+ if (process.platform === "win32") {
25136
+ return spawn5(
25137
+ "powershell.exe",
25138
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", input.command],
25139
+ {
25140
+ env,
25141
+ stdio: ["ignore", "pipe", "pipe"],
25142
+ windowsHide: true,
25143
+ detached: false,
25144
+ shell: false
25145
+ }
25146
+ );
25147
+ }
25148
+ return spawn5("/bin/sh", ["-lc", input.command], {
25149
+ env,
25150
+ stdio: ["ignore", "pipe", "pipe"],
25151
+ windowsHide: true,
25152
+ detached: false,
25153
+ shell: false
23774
25154
  });
23775
- const payload = await response.json().catch(() => null);
23776
- const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
23777
- const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
23778
- const values = [
23779
- readIpRecord(record?.ipv4),
23780
- readIpRecord(record?.ipv6),
23781
- typeof observed?.ip === "string" ? observed.ip : null
23782
- ].filter((value) => Boolean(value));
25155
+ }
25156
+ async function readLinkReleaseCheckContext(paths) {
25157
+ const config = await loadConfig(paths);
25158
+ const url = new URL(SERVER_LINK_CURRENT_RELEASE_PATH, config.serverBaseUrl);
25159
+ url.searchParams.set("channel", "stable");
25160
+ url.searchParams.set("lang", "en");
23783
25161
  return {
23784
- publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
23785
- publicIpv6s: unique(values.filter(isUsablePublicIpv6))
25162
+ serverBaseUrl: config.serverBaseUrl,
25163
+ releaseCheckUrl: url.toString()
23786
25164
  };
23787
25165
  }
23788
- function readIpRecord(value) {
23789
- if (typeof value !== "object" || value === null) {
23790
- return null;
25166
+ function computeLinkUpdateState(localVersion, remote) {
25167
+ if (!remote?.current_version) {
25168
+ return "unknown";
23791
25169
  }
23792
- const ip = value.ip;
23793
- return typeof ip === "string" && ip.trim() ? ip.trim() : null;
25170
+ if (remote.min_safe_version && compareSemver3(localVersion, remote.min_safe_version) < 0) {
25171
+ return "unsafe";
25172
+ }
25173
+ const diff = compareSemver3(localVersion, remote.current_version);
25174
+ if (diff < 0) {
25175
+ return "update_available";
25176
+ }
25177
+ if (diff > 0) {
25178
+ return "ahead_of_current";
25179
+ }
25180
+ return "current";
23794
25181
  }
23795
- function buildDirectUrl(ip, port) {
23796
- return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
25182
+ async function emitUpdateStatus2(paths) {
25183
+ updateEvents2.emit("status", await readLinkUpdateStatus(paths));
23797
25184
  }
23798
- function shouldIgnoreInterface(name) {
23799
- return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
25185
+ async function writeUpdateState2(paths, state) {
25186
+ await writeJsonFile(updateStatePath2(paths), state);
23800
25187
  }
23801
- function compareLanCandidate(left, right) {
23802
- const priority = interfacePriority(left.name) - interfacePriority(right.name);
23803
- return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
25188
+ async function readUpdateLogLines2(paths) {
25189
+ const raw = await readFile18(updateLogPath2(paths), "utf8").catch(() => "");
25190
+ if (!raw.trim()) {
25191
+ return [];
25192
+ }
25193
+ return raw.split(/\r?\n/u).map((line) => line.trimEnd()).filter(Boolean).slice(-MAX_UPDATE_LOG_LINES2).map(
25194
+ (line) => line.length > MAX_OUTPUT_LINE_LENGTH3 ? `${line.slice(0, MAX_OUTPUT_LINE_LENGTH3)}...` : line
25195
+ );
23804
25196
  }
23805
- function interfacePriority(name) {
23806
- if (/^(en|eth|wlan|wi-fi|wifi)/iu.test(name)) {
23807
- return 0;
25197
+ function updateStatePath2(paths) {
25198
+ return path25.join(paths.runDir, "link-update-state.json");
25199
+ }
25200
+ function updateLogPath2(paths) {
25201
+ return path25.join(paths.logsDir, UPDATE_LOG_FILE2);
25202
+ }
25203
+ async function clearUpdateLogFiles2(paths) {
25204
+ const primary = updateLogPath2(paths);
25205
+ await Promise.all([
25206
+ rm10(primary, { force: true }).catch(() => void 0),
25207
+ ...Array.from(
25208
+ { length: UPDATE_LOG_MAX_FILES2 },
25209
+ (_, index) => rm10(`${primary}.${index + 1}`, { force: true }).catch(() => void 0)
25210
+ )
25211
+ ]);
25212
+ }
25213
+ function manualInstallCommand(version) {
25214
+ return `npm install -g ${LINK_NPM_PACKAGE}@${version}`;
25215
+ }
25216
+ function isValidReleaseVersion(version) {
25217
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(
25218
+ version
25219
+ );
25220
+ }
25221
+ function compareSemver3(left, right) {
25222
+ const leftParts = parseSemver(left);
25223
+ const rightParts = parseSemver(right);
25224
+ for (let index = 0; index < 3; index += 1) {
25225
+ const diff = leftParts[index] - rightParts[index];
25226
+ if (diff !== 0) {
25227
+ return diff;
25228
+ }
23808
25229
  }
23809
- return 1;
25230
+ return 0;
23810
25231
  }
23811
- function isUsableLanIpv42(address, netmask) {
23812
- return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
25232
+ function parseSemver(value) {
25233
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/u.exec(value.trim());
25234
+ return [
25235
+ Number.parseInt(match?.[1] ?? "0", 10),
25236
+ Number.parseInt(match?.[2] ?? "0", 10),
25237
+ Number.parseInt(match?.[3] ?? "0", 10)
25238
+ ];
23813
25239
  }
23814
- function isUsablePublicIpv4(address) {
23815
- return isValidIpv4(address) && !isSpecialIpv4(address);
25240
+ function quoteShellToken(value) {
25241
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) {
25242
+ return value;
25243
+ }
25244
+ return `'${value.replaceAll("'", "'\\''")}'`;
23816
25245
  }
23817
- function isUsablePublicIpv6(address) {
23818
- const normalized = address.toLowerCase();
23819
- return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
25246
+ function quotePowerShellString(value) {
25247
+ return `'${value.replaceAll("'", "''")}'`;
23820
25248
  }
23821
- function isPrivateIpv4(address) {
23822
- const parts = parseIpv4Segments(address);
23823
- if (!parts) {
25249
+ function isRecentRunningState3(state, now = Date.now()) {
25250
+ const startedAt = state.started_at ? Date.parse(state.started_at) : Number.NaN;
25251
+ return Number.isFinite(startedAt) && now - startedAt < RUNNING_STATE_GRACE_MS;
25252
+ }
25253
+ function isRunningStateTimedOut(state, now = Date.now()) {
25254
+ const startedAt = state.started_at ? Date.parse(state.started_at) : Number.NaN;
25255
+ return Number.isFinite(startedAt) && now - startedAt >= LINK_UPDATE_TIMEOUT_MS;
25256
+ }
25257
+ function linkUpdateTimeoutError() {
25258
+ return `Link update timed out after ${Math.round(
25259
+ LINK_UPDATE_TIMEOUT_MS / 6e4
25260
+ )} minutes.`;
25261
+ }
25262
+ function isProcessAlive4(pid) {
25263
+ if (!pid || pid <= 0) {
23824
25264
  return false;
23825
25265
  }
23826
- const [first, second] = parts;
23827
- return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
23828
- }
23829
- function isSpecialIpv4(address) {
23830
- const parts = parseIpv4Segments(address);
23831
- if (!parts) {
25266
+ try {
25267
+ process.kill(pid, 0);
23832
25268
  return true;
25269
+ } catch {
25270
+ return false;
23833
25271
  }
23834
- const [first, second, third, fourth] = parts;
23835
- return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
23836
25272
  }
23837
- function isNetworkOrBroadcastIpv4Address(address, netmask) {
23838
- const addressParts = parseIpv4Segments(address);
23839
- const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
23840
- if (!addressParts) {
23841
- return true;
25273
+ function toRecord17(value) {
25274
+ return typeof value === "object" && value !== null ? value : {};
25275
+ }
25276
+ function toNullableRecord2(value) {
25277
+ return typeof value === "object" && value !== null ? value : null;
25278
+ }
25279
+ function readString18(payload, key) {
25280
+ const value = payload[key];
25281
+ return typeof value === "string" && value.trim() ? value.trim() : null;
25282
+ }
25283
+
25284
+ // src/pairing/pairing.ts
25285
+ import path26 from "path";
25286
+ import { rm as rm11 } from "fs/promises";
25287
+
25288
+ // src/relay/bootstrap.ts
25289
+ var RelayNetworkError = class extends Error {
25290
+ constructor(relayBaseUrl, causeMessage) {
25291
+ super(
25292
+ `Cannot reach Hermes Relay at ${relayBaseUrl}. Please check your network connection, VPN, or proxy settings, then try again.`
25293
+ );
25294
+ this.relayBaseUrl = relayBaseUrl;
25295
+ this.causeMessage = causeMessage;
23842
25296
  }
23843
- if (!netmaskParts) {
23844
- const last = addressParts[3];
23845
- return last === 0 || last === 255;
25297
+ relayBaseUrl;
25298
+ causeMessage;
25299
+ };
25300
+ async function bootstrapRelayLink(options) {
25301
+ const fetcher = options.fetchImpl ?? fetch;
25302
+ const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
25303
+ const commonPayload = {
25304
+ install_id: options.identity.install_id,
25305
+ link_id: options.identity.link_id ?? void 0,
25306
+ public_key_pem: options.identity.public_key_pem
25307
+ };
25308
+ const challenge = await postJson(
25309
+ fetcher,
25310
+ `${baseUrl}/api/v1/relay/link/challenge`,
25311
+ options.relayBootstrapToken,
25312
+ commonPayload
25313
+ );
25314
+ if (challenge.ok !== true || typeof challenge.nonce !== "string") {
25315
+ throw new Error("Relay did not return a valid install challenge");
23846
25316
  }
23847
- const addressInt = ipv4SegmentsToInt(addressParts);
23848
- const netmaskInt = ipv4SegmentsToInt(netmaskParts);
23849
- const hostMask = ~netmaskInt >>> 0;
23850
- if (hostMask === 0) {
23851
- return false;
25317
+ const proof = {
25318
+ nonce: challenge.nonce,
25319
+ signature: signRelayNonce(options.identity, challenge.nonce)
25320
+ };
25321
+ const assigned = await postJson(
25322
+ fetcher,
25323
+ `${baseUrl}/api/v1/relay/link/bootstrap`,
25324
+ options.relayBootstrapToken,
25325
+ {
25326
+ ...commonPayload,
25327
+ proof
25328
+ }
25329
+ );
25330
+ if (assigned.ok !== true || typeof assigned.link_id !== "string") {
25331
+ throw new Error("Relay did not return a valid link_id");
23852
25332
  }
23853
- const networkInt = addressInt & netmaskInt;
23854
- const broadcastInt = (networkInt | hostMask) >>> 0;
23855
- return addressInt === networkInt || addressInt === broadcastInt;
25333
+ await saveAssignedLinkId(assigned.link_id, options.paths ?? resolveRuntimePaths());
25334
+ return {
25335
+ linkId: assigned.link_id,
25336
+ reused: assigned.reused === true
25337
+ };
23856
25338
  }
23857
- function isValidIpv4(address) {
23858
- return Boolean(parseIpv4Segments(address));
25339
+ async function postJson(fetcher, url, token, body) {
25340
+ let response;
25341
+ try {
25342
+ response = await fetcher(url, {
25343
+ method: "POST",
25344
+ headers: {
25345
+ authorization: `Bearer ${token}`,
25346
+ "content-type": "application/json"
25347
+ },
25348
+ body: JSON.stringify(body)
25349
+ });
25350
+ } catch (error) {
25351
+ const baseUrl = new URL(url).origin;
25352
+ throw new RelayNetworkError(
25353
+ baseUrl,
25354
+ error instanceof Error ? error.message : String(error)
25355
+ );
25356
+ }
25357
+ const payload = await response.json().catch(() => null);
25358
+ if (!response.ok) {
25359
+ const message = readErrorMessage4(payload) ?? `Relay request failed with HTTP ${response.status}`;
25360
+ throw new Error(message);
25361
+ }
25362
+ if (!payload) {
25363
+ throw new Error("Relay returned an empty response");
25364
+ }
25365
+ return payload;
23859
25366
  }
23860
- function parseIpv4Segments(address) {
23861
- if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) {
25367
+ function readErrorMessage4(payload) {
25368
+ if (typeof payload !== "object" || payload === null) {
23862
25369
  return null;
23863
25370
  }
23864
- const parts = address.split(".").map((part) => Number.parseInt(part, 10));
23865
- if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
25371
+ const error = payload.error;
25372
+ if (typeof error !== "object" || error === null) {
23866
25373
  return null;
23867
25374
  }
23868
- return parts;
23869
- }
23870
- function ipv4SegmentsToInt(parts) {
23871
- return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
23872
- }
23873
- function unique(values) {
23874
- return [...new Set(values)];
25375
+ const message = error.message;
25376
+ return typeof message === "string" ? message : null;
23875
25377
  }
23876
25378
 
23877
25379
  // src/pairing/pairing.ts
@@ -24043,7 +25545,7 @@ async function readPairingClaim(sessionId, paths = resolveRuntimePaths()) {
24043
25545
  };
24044
25546
  }
24045
25547
  async function clearPairingClaim(sessionId, paths = resolveRuntimePaths()) {
24046
- await rm9(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
25548
+ await rm11(pairingClaimPath(sessionId, paths), { force: true }).catch(() => void 0);
24047
25549
  }
24048
25550
  async function claimPairing(input) {
24049
25551
  const paths = input.paths ?? resolveRuntimePaths();
@@ -24120,10 +25622,10 @@ async function loadRequiredIdentity2(paths) {
24120
25622
  }
24121
25623
  return identity;
24122
25624
  }
24123
- async function postServerJson(serverBaseUrl, path26, body, options) {
25625
+ async function postServerJson(serverBaseUrl, path27, body, options) {
24124
25626
  let response;
24125
25627
  try {
24126
- response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path26}`, {
25628
+ response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path27}`, {
24127
25629
  method: "POST",
24128
25630
  headers: {
24129
25631
  accept: "application/json",
@@ -24171,10 +25673,10 @@ function pairingErrorSnapshot(stage, error) {
24171
25673
  occurred_at: (/* @__PURE__ */ new Date()).toISOString()
24172
25674
  };
24173
25675
  }
24174
- async function patchServerJson(serverBaseUrl, path26, token, body, options) {
25676
+ async function patchServerJson(serverBaseUrl, path27, token, body, options) {
24175
25677
  let response;
24176
25678
  try {
24177
- response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path26}`, {
25679
+ response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${path27}`, {
24178
25680
  method: "PATCH",
24179
25681
  headers: {
24180
25682
  accept: "application/json",
@@ -24196,12 +25698,12 @@ async function patchServerJson(serverBaseUrl, path26, token, body, options) {
24196
25698
  async function readJsonResponse2(response) {
24197
25699
  const payload = await response.json().catch(() => null);
24198
25700
  if (!response.ok || !payload) {
24199
- const message = readErrorMessage4(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
25701
+ const message = readErrorMessage5(payload) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
24200
25702
  throw new LinkHttpError(response.status, "server_request_failed", message);
24201
25703
  }
24202
25704
  return payload;
24203
25705
  }
24204
- function readErrorMessage4(payload) {
25706
+ function readErrorMessage5(payload) {
24205
25707
  if (typeof payload !== "object" || payload === null) {
24206
25708
  return null;
24207
25709
  }
@@ -24222,10 +25724,10 @@ function createPairingNetworkError(input) {
24222
25724
  );
24223
25725
  }
24224
25726
  function pairingClaimPath(sessionId, paths) {
24225
- return path25.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
25727
+ return path26.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
24226
25728
  }
24227
25729
  function pairingSessionPath(sessionId, paths) {
24228
- return path25.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
25730
+ return path26.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.json`);
24229
25731
  }
24230
25732
  function qrPreferredUrls(routes) {
24231
25733
  return routes.preferredUrls.filter((url) => !url.includes("/api/v1/relay/links/")).slice(0, 1);
@@ -25399,20 +26901,14 @@ async function createApp(options = {}) {
25399
26901
  export {
25400
26902
  LINK_VERSION,
25401
26903
  LINK_COMMAND,
25402
- migrateLinkDatabase,
25403
26904
  LinkHttpError,
25404
- readJsonFile,
25405
- writeJsonFile,
25406
26905
  resolveHermesProfileDir,
25407
26906
  resolveHermesConfigPath,
25408
26907
  readHermesApiServerConfig,
25409
26908
  ensureHermesApiServerConfig,
25410
- syncHermesLinkCronDeliveries,
25411
26909
  resolveRuntimePaths,
25412
26910
  createFileLogger,
25413
26911
  getLinkLogFile,
25414
- getDaemonLogFile,
25415
- createRotatingTextLogWriter,
25416
26912
  ensureHermesApiServerAvailable,
25417
26913
  readHermesVersion,
25418
26914
  defaultLinkConfig,
@@ -25422,15 +26918,23 @@ export {
25422
26918
  normalizeLanHost,
25423
26919
  loadIdentity,
25424
26920
  ensureIdentity,
25425
- signIdentityPayload,
25426
26921
  getIdentityStatus,
25427
26922
  ConversationService,
25428
26923
  hasActiveDevices,
25429
- readLinkSystemInfo,
25430
26924
  detectRuntimeEnvironment,
25431
- discoverRouteCandidates,
25432
26925
  preparePairing,
25433
26926
  readPairingClaim,
25434
26927
  clearPairingClaim,
25435
- createApp
26928
+ createApp,
26929
+ fetchRelayStreamBatchPolicy,
26930
+ connectRelayControl,
26931
+ reportLinkStatusToServer,
26932
+ startLinkService,
26933
+ startDaemonProcess,
26934
+ runDaemonSupervisor,
26935
+ probeLocalLinkService,
26936
+ stopDaemonProcess,
26937
+ getDaemonStatus,
26938
+ daemonLogFile,
26939
+ currentCliScriptPath
25436
26940
  };