@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.
- package/dist/{chunk-DZMN5RIV.js → chunk-KFEEZ4LM.js} +2334 -830
- package/dist/cli/index.js +39 -1216
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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.
|
|
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(
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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}${
|
|
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:
|
|
13128
|
+
path: path27,
|
|
13129
13129
|
profile: options.profileName ?? "default",
|
|
13130
13130
|
port: config.port ?? null,
|
|
13131
|
-
url: `http://127.0.0.1:${config.port}${
|
|
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,
|
|
13141
|
+
function logHermesApiResponse(logger, method, path27, profileName, startedAt, response) {
|
|
13142
13142
|
const fields = {
|
|
13143
13143
|
method,
|
|
13144
|
-
path:
|
|
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,
|
|
13165
|
+
function logHermesApiError(logger, method, path27, profileName, startedAt, error) {
|
|
13166
13166
|
void logger?.warn("hermes_api_request_failed", {
|
|
13167
13167
|
method,
|
|
13168
|
-
path:
|
|
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
|
|
22952
|
+
import { spawn as spawn5 } from "child_process";
|
|
22953
22953
|
import { EventEmitter as EventEmitter4 } from "events";
|
|
22954
|
-
import { mkdir as
|
|
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
|
-
|
|
22957
|
-
|
|
22958
|
-
|
|
22959
|
-
|
|
22960
|
-
|
|
22961
|
-
|
|
22962
|
-
|
|
22963
|
-
|
|
22964
|
-
|
|
22965
|
-
var
|
|
22966
|
-
|
|
22967
|
-
|
|
22968
|
-
|
|
22969
|
-
|
|
22970
|
-
|
|
22971
|
-
|
|
22972
|
-
|
|
22973
|
-
|
|
22974
|
-
|
|
22975
|
-
|
|
22976
|
-
|
|
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
|
-
|
|
23007
|
-
|
|
23008
|
-
|
|
23009
|
-
|
|
23010
|
-
|
|
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
|
-
|
|
23014
|
-
|
|
23015
|
-
|
|
23016
|
-
|
|
23017
|
-
|
|
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
|
-
|
|
23021
|
-
|
|
23022
|
-
|
|
23023
|
-
|
|
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
|
-
|
|
23028
|
-
|
|
23029
|
-
|
|
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
|
-
|
|
23052
|
-
|
|
23053
|
-
|
|
23054
|
-
|
|
23055
|
-
|
|
23056
|
-
);
|
|
23057
|
-
|
|
23058
|
-
|
|
23059
|
-
|
|
23060
|
-
|
|
23061
|
-
|
|
23062
|
-
|
|
23063
|
-
|
|
23064
|
-
|
|
23065
|
-
|
|
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
|
-
|
|
23068
|
-
|
|
23069
|
-
|
|
23070
|
-
|
|
23071
|
-
|
|
23072
|
-
|
|
23073
|
-
|
|
23074
|
-
|
|
23075
|
-
|
|
23076
|
-
|
|
23077
|
-
|
|
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
|
-
|
|
23080
|
-
|
|
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
|
-
|
|
23095
|
-
}
|
|
23096
|
-
|
|
23097
|
-
|
|
23098
|
-
|
|
23099
|
-
|
|
23100
|
-
|
|
23101
|
-
|
|
23102
|
-
|
|
23103
|
-
|
|
23104
|
-
|
|
23105
|
-
|
|
23106
|
-
|
|
23107
|
-
|
|
23108
|
-
|
|
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
|
-
}
|
|
23145
|
-
|
|
23146
|
-
|
|
23147
|
-
|
|
23148
|
-
|
|
23149
|
-
|
|
23150
|
-
|
|
23151
|
-
|
|
23152
|
-
|
|
23153
|
-
|
|
23154
|
-
|
|
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
|
-
|
|
23157
|
-
|
|
23158
|
-
|
|
23159
|
-
|
|
23160
|
-
|
|
23161
|
-
|
|
23162
|
-
|
|
23163
|
-
|
|
23164
|
-
|
|
23165
|
-
|
|
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
|
-
|
|
23169
|
-
|
|
23170
|
-
|
|
23171
|
-
|
|
23172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23204
|
-
const
|
|
23205
|
-
const
|
|
23206
|
-
|
|
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
|
|
23222
|
-
const
|
|
23223
|
-
|
|
23224
|
-
|
|
23225
|
-
|
|
23226
|
-
|
|
23227
|
-
|
|
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
|
-
|
|
23257
|
-
|
|
23258
|
-
|
|
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
|
-
|
|
23266
|
-
|
|
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
|
-
|
|
23275
|
-
|
|
23276
|
-
|
|
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
|
-
|
|
23294
|
-
|
|
23295
|
-
|
|
23296
|
-
|
|
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 (
|
|
23302
|
-
|
|
23214
|
+
if (abortController.signal.aborted || isAbortError2(error)) {
|
|
23215
|
+
return;
|
|
23303
23216
|
}
|
|
23304
|
-
|
|
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
|
-
|
|
23221
|
+
sseBatcher?.dispose();
|
|
23222
|
+
abortControllers.delete(frame.id);
|
|
23307
23223
|
}
|
|
23308
23224
|
}
|
|
23309
|
-
function
|
|
23310
|
-
|
|
23311
|
-
|
|
23312
|
-
|
|
23313
|
-
|
|
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
|
-
|
|
23316
|
-
|
|
23317
|
-
|
|
23318
|
-
|
|
23319
|
-
|
|
23320
|
-
|
|
23321
|
-
|
|
23322
|
-
|
|
23323
|
-
|
|
23324
|
-
|
|
23325
|
-
|
|
23326
|
-
|
|
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
|
-
|
|
23331
|
-
|
|
23332
|
-
|
|
23333
|
-
|
|
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
|
-
|
|
23336
|
-
|
|
23337
|
-
|
|
23338
|
-
|
|
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
|
-
|
|
23349
|
-
|
|
23350
|
-
|
|
23351
|
-
|
|
23352
|
-
|
|
23353
|
-
|
|
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
|
-
|
|
23357
|
-
|
|
23358
|
-
|
|
23359
|
-
|
|
23360
|
-
|
|
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
|
-
|
|
23363
|
-
|
|
23295
|
+
platform,
|
|
23296
|
+
hostname,
|
|
23297
|
+
osLabel,
|
|
23298
|
+
defaultDisplayName
|
|
23364
23299
|
};
|
|
23365
23300
|
}
|
|
23366
|
-
function
|
|
23367
|
-
|
|
23368
|
-
|
|
23369
|
-
|
|
23370
|
-
|
|
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
|
|
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
|
-
|
|
23389
|
-
const
|
|
23390
|
-
|
|
23391
|
-
|
|
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
|
|
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
|
|
23398
|
-
|
|
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
|
|
23401
|
-
|
|
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
|
-
|
|
23404
|
-
const
|
|
23405
|
-
|
|
23406
|
-
|
|
23407
|
-
|
|
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
|
|
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
|
|
23445
|
-
if (!pid || pid <= 0) {
|
|
23446
|
-
return false;
|
|
23447
|
-
}
|
|
23351
|
+
function readCommandOutput(command, args) {
|
|
23448
23352
|
try {
|
|
23449
|
-
|
|
23450
|
-
|
|
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
|
|
23360
|
+
return null;
|
|
23453
23361
|
}
|
|
23454
23362
|
}
|
|
23455
|
-
function
|
|
23456
|
-
|
|
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
|
|
23459
|
-
|
|
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
|
|
23462
|
-
const
|
|
23463
|
-
return
|
|
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/
|
|
23467
|
-
import
|
|
23468
|
-
import { rm as rm9 } from "fs/promises";
|
|
23386
|
+
// src/topology/network.ts
|
|
23387
|
+
import os6 from "os";
|
|
23469
23388
|
|
|
23470
|
-
// src/
|
|
23471
|
-
|
|
23472
|
-
|
|
23473
|
-
|
|
23474
|
-
|
|
23475
|
-
|
|
23476
|
-
|
|
23477
|
-
|
|
23478
|
-
|
|
23479
|
-
|
|
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
|
-
|
|
23500
|
-
|
|
23501
|
-
|
|
23502
|
-
|
|
23503
|
-
|
|
23504
|
-
|
|
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
|
-
|
|
23518
|
-
|
|
23408
|
+
kind: "native",
|
|
23409
|
+
lanAutoDiscoveryUsable: true,
|
|
23410
|
+
warning: null
|
|
23519
23411
|
};
|
|
23520
23412
|
}
|
|
23521
|
-
|
|
23522
|
-
|
|
23523
|
-
|
|
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 (
|
|
23545
|
-
|
|
23417
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
|
|
23418
|
+
return true;
|
|
23546
23419
|
}
|
|
23547
|
-
|
|
23420
|
+
const release = os5.release().toLowerCase();
|
|
23421
|
+
return release.includes("microsoft") || release.includes("wsl");
|
|
23548
23422
|
}
|
|
23549
|
-
function
|
|
23550
|
-
if (
|
|
23551
|
-
return
|
|
23423
|
+
function isContainer(env) {
|
|
23424
|
+
if (env.container || env.CONTAINER || env.KUBERNETES_SERVICE_HOST) {
|
|
23425
|
+
return true;
|
|
23552
23426
|
}
|
|
23553
|
-
|
|
23554
|
-
|
|
23555
|
-
|
|
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/
|
|
23562
|
-
|
|
23563
|
-
|
|
23564
|
-
|
|
23565
|
-
|
|
23566
|
-
|
|
23567
|
-
const
|
|
23568
|
-
const
|
|
23569
|
-
const
|
|
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
|
-
|
|
23572
|
-
|
|
23573
|
-
|
|
23574
|
-
|
|
23457
|
+
lanIps,
|
|
23458
|
+
publicIpv4s,
|
|
23459
|
+
publicIpv6s,
|
|
23460
|
+
preferredUrls,
|
|
23461
|
+
environment
|
|
23575
23462
|
};
|
|
23576
23463
|
}
|
|
23577
|
-
function
|
|
23578
|
-
|
|
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
|
|
23586
|
-
const
|
|
23587
|
-
|
|
23588
|
-
|
|
23589
|
-
if (
|
|
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
|
-
|
|
23593
|
-
|
|
23594
|
-
|
|
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
|
|
23597
|
-
|
|
23598
|
-
|
|
23599
|
-
|
|
23600
|
-
|
|
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
|
-
|
|
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
|
|
23606
|
-
if (
|
|
23607
|
-
|
|
23608
|
-
|
|
23710
|
+
function normalizeNetworkSnapshot(value) {
|
|
23711
|
+
if (Array.isArray(value)) {
|
|
23712
|
+
return {
|
|
23713
|
+
lanIps: normalizeLanIps(value),
|
|
23714
|
+
publicIpv4s: [],
|
|
23715
|
+
publicIpv6s: []
|
|
23716
|
+
};
|
|
23609
23717
|
}
|
|
23610
|
-
|
|
23611
|
-
|
|
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
|
-
|
|
23614
|
-
|
|
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
|
|
23619
|
-
|
|
23620
|
-
|
|
23621
|
-
|
|
23622
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
23630
|
-
|
|
23631
|
-
|
|
23632
|
-
|
|
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
|
-
|
|
23635
|
-
|
|
23636
|
-
|
|
23637
|
-
|
|
23638
|
-
|
|
23639
|
-
|
|
23640
|
-
|
|
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
|
-
|
|
23663
|
-
|
|
23664
|
-
|
|
23665
|
-
|
|
23666
|
-
|
|
23667
|
-
|
|
23668
|
-
|
|
23669
|
-
|
|
23670
|
-
|
|
23671
|
-
|
|
23672
|
-
|
|
23673
|
-
|
|
23674
|
-
|
|
23675
|
-
|
|
23676
|
-
|
|
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
|
-
|
|
23679
|
-
|
|
23680
|
-
|
|
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
|
-
|
|
23685
|
-
|
|
23686
|
-
|
|
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
|
|
23690
|
-
|
|
23691
|
-
|
|
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
|
-
|
|
23694
|
-
|
|
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
|
-
|
|
23697
|
-
|
|
24974
|
+
return {
|
|
24975
|
+
unixUrl,
|
|
24976
|
+
windowsUrl
|
|
24977
|
+
};
|
|
23698
24978
|
}
|
|
23699
|
-
function
|
|
23700
|
-
|
|
23701
|
-
|
|
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
|
-
|
|
23704
|
-
|
|
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
|
|
23708
|
-
|
|
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
|
-
|
|
23715
|
-
|
|
23716
|
-
|
|
23717
|
-
|
|
23718
|
-
|
|
23719
|
-
|
|
23720
|
-
|
|
23721
|
-
|
|
23722
|
-
|
|
23723
|
-
|
|
23724
|
-
|
|
23725
|
-
|
|
23726
|
-
|
|
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
|
-
|
|
23762
|
-
const
|
|
23763
|
-
|
|
23764
|
-
|
|
23765
|
-
|
|
23766
|
-
"
|
|
23767
|
-
|
|
23768
|
-
|
|
23769
|
-
|
|
23770
|
-
|
|
23771
|
-
|
|
23772
|
-
|
|
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
|
-
|
|
23776
|
-
|
|
23777
|
-
const
|
|
23778
|
-
const
|
|
23779
|
-
|
|
23780
|
-
|
|
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
|
-
|
|
23785
|
-
|
|
25162
|
+
serverBaseUrl: config.serverBaseUrl,
|
|
25163
|
+
releaseCheckUrl: url.toString()
|
|
23786
25164
|
};
|
|
23787
25165
|
}
|
|
23788
|
-
function
|
|
23789
|
-
if (
|
|
23790
|
-
return
|
|
25166
|
+
function computeLinkUpdateState(localVersion, remote) {
|
|
25167
|
+
if (!remote?.current_version) {
|
|
25168
|
+
return "unknown";
|
|
23791
25169
|
}
|
|
23792
|
-
|
|
23793
|
-
|
|
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
|
|
23796
|
-
|
|
25182
|
+
async function emitUpdateStatus2(paths) {
|
|
25183
|
+
updateEvents2.emit("status", await readLinkUpdateStatus(paths));
|
|
23797
25184
|
}
|
|
23798
|
-
function
|
|
23799
|
-
|
|
25185
|
+
async function writeUpdateState2(paths, state) {
|
|
25186
|
+
await writeJsonFile(updateStatePath2(paths), state);
|
|
23800
25187
|
}
|
|
23801
|
-
function
|
|
23802
|
-
const
|
|
23803
|
-
|
|
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
|
|
23806
|
-
|
|
23807
|
-
|
|
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
|
|
25230
|
+
return 0;
|
|
23810
25231
|
}
|
|
23811
|
-
function
|
|
23812
|
-
|
|
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
|
|
23815
|
-
|
|
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
|
|
23818
|
-
|
|
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
|
|
23822
|
-
const
|
|
23823
|
-
|
|
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
|
-
|
|
23827
|
-
|
|
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
|
|
23838
|
-
|
|
23839
|
-
|
|
23840
|
-
|
|
23841
|
-
|
|
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
|
-
|
|
23844
|
-
|
|
23845
|
-
|
|
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
|
|
23848
|
-
|
|
23849
|
-
|
|
23850
|
-
|
|
23851
|
-
|
|
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
|
-
|
|
23854
|
-
|
|
23855
|
-
|
|
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
|
|
23858
|
-
|
|
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
|
|
23861
|
-
if (
|
|
25367
|
+
function readErrorMessage4(payload) {
|
|
25368
|
+
if (typeof payload !== "object" || payload === null) {
|
|
23862
25369
|
return null;
|
|
23863
25370
|
}
|
|
23864
|
-
const
|
|
23865
|
-
if (
|
|
25371
|
+
const error = payload.error;
|
|
25372
|
+
if (typeof error !== "object" || error === null) {
|
|
23866
25373
|
return null;
|
|
23867
25374
|
}
|
|
23868
|
-
|
|
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
|
|
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,
|
|
25625
|
+
async function postServerJson(serverBaseUrl, path27, body, options) {
|
|
24124
25626
|
let response;
|
|
24125
25627
|
try {
|
|
24126
|
-
response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${
|
|
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,
|
|
25676
|
+
async function patchServerJson(serverBaseUrl, path27, token, body, options) {
|
|
24175
25677
|
let response;
|
|
24176
25678
|
try {
|
|
24177
|
-
response = await fetch(`${serverBaseUrl.replace(/\/+$/u, "")}${
|
|
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 =
|
|
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
|
|
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
|
|
25727
|
+
return path26.join(paths.pairingDir, `${Buffer.from(sessionId).toString("base64url")}.claimed.json`);
|
|
24226
25728
|
}
|
|
24227
25729
|
function pairingSessionPath(sessionId, paths) {
|
|
24228
|
-
return
|
|
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
|
};
|