@hermespilot/link 0.5.1 → 0.5.2
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/README.md +8 -2
- package/dist/{chunk-PULX22HX.js → chunk-DZMN5RIV.js} +1553 -1912
- package/dist/cli/index.js +1508 -41
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.mjs +6 -0
package/dist/cli/index.js
CHANGED
|
@@ -5,53 +5,1231 @@ import {
|
|
|
5
5
|
LINK_VERSION,
|
|
6
6
|
LinkHttpError,
|
|
7
7
|
clearPairingClaim,
|
|
8
|
-
|
|
8
|
+
createApp,
|
|
9
9
|
createFileLogger,
|
|
10
|
-
|
|
11
|
-
daemonLogFile,
|
|
10
|
+
createRotatingTextLogWriter,
|
|
12
11
|
defaultLinkConfig,
|
|
13
12
|
detectRuntimeEnvironment,
|
|
13
|
+
discoverRouteCandidates,
|
|
14
14
|
ensureHermesApiServerAvailable,
|
|
15
15
|
ensureHermesApiServerConfig,
|
|
16
16
|
ensureIdentity,
|
|
17
|
-
|
|
17
|
+
getDaemonLogFile,
|
|
18
18
|
getIdentityStatus,
|
|
19
19
|
getLinkLogFile,
|
|
20
20
|
hasActiveDevices,
|
|
21
21
|
loadConfig,
|
|
22
22
|
loadIdentity,
|
|
23
|
+
migrateLinkDatabase,
|
|
23
24
|
normalizeLanHost,
|
|
24
25
|
parseLogLevel,
|
|
25
26
|
preparePairing,
|
|
26
|
-
probeLocalLinkService,
|
|
27
27
|
readHermesApiServerConfig,
|
|
28
28
|
readHermesVersion,
|
|
29
|
+
readJsonFile,
|
|
30
|
+
readLinkSystemInfo,
|
|
29
31
|
readPairingClaim,
|
|
30
|
-
reportLinkStatusToServer,
|
|
31
32
|
resolveHermesConfigPath,
|
|
32
33
|
resolveHermesProfileDir,
|
|
33
34
|
resolveRuntimePaths,
|
|
34
|
-
runDaemonSupervisor,
|
|
35
35
|
saveConfig,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
} from "../chunk-
|
|
36
|
+
signIdentityPayload,
|
|
37
|
+
syncHermesLinkCronDeliveries,
|
|
38
|
+
writeJsonFile
|
|
39
|
+
} from "../chunk-DZMN5RIV.js";
|
|
40
40
|
|
|
41
41
|
// src/cli/index.ts
|
|
42
42
|
import { Command } from "commander";
|
|
43
|
-
import { realpathSync } from "fs";
|
|
44
|
-
import
|
|
43
|
+
import { realpathSync as realpathSync2 } from "fs";
|
|
44
|
+
import path5 from "path";
|
|
45
45
|
import { createInterface } from "readline/promises";
|
|
46
46
|
import { pathToFileURL } from "url";
|
|
47
47
|
import qrcode from "qrcode-terminal";
|
|
48
48
|
|
|
49
49
|
// src/autostart/autostart.ts
|
|
50
50
|
import { execFile } from "child_process";
|
|
51
|
-
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
51
|
+
import { mkdir as mkdir3, readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
|
|
52
52
|
import os from "os";
|
|
53
|
-
import
|
|
53
|
+
import path2 from "path";
|
|
54
54
|
import { promisify } from "util";
|
|
55
|
+
|
|
56
|
+
// src/daemon/process.ts
|
|
57
|
+
import { spawn } from "child_process";
|
|
58
|
+
import { mkdir as mkdir2, readFile, rm as rm2 } from "fs/promises";
|
|
59
|
+
import path from "path";
|
|
60
|
+
|
|
61
|
+
// src/daemon/service.ts
|
|
62
|
+
import { createServer } from "http";
|
|
63
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
64
|
+
|
|
65
|
+
// src/relay/control-client.ts
|
|
66
|
+
import WebSocket from "ws";
|
|
67
|
+
|
|
68
|
+
// src/relay/stream-policy.ts
|
|
69
|
+
var DEFAULT_RELAY_STREAM_BATCH_POLICY = {
|
|
70
|
+
flushIntervalMs: 50,
|
|
71
|
+
flushBytes: 2 * 1024
|
|
72
|
+
};
|
|
73
|
+
var RELAY_STREAM_POLICY_CONSTRAINTS = {
|
|
74
|
+
flushIntervalMs: {
|
|
75
|
+
min: 50,
|
|
76
|
+
max: 1e3
|
|
77
|
+
},
|
|
78
|
+
flushBytes: {
|
|
79
|
+
min: 1024,
|
|
80
|
+
max: 64 * 1024
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
async function fetchRelayStreamBatchPolicy(serverBaseUrl, options = {}) {
|
|
84
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e3);
|
|
87
|
+
timeout.unref?.();
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetchImpl(`${serverBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/stream-policy`, {
|
|
90
|
+
headers: {
|
|
91
|
+
accept: "application/json"
|
|
92
|
+
},
|
|
93
|
+
signal: controller.signal
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const payload = await response.json().catch(() => null);
|
|
99
|
+
return readRelayStreamBatchPolicy(payload);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function readRelayStreamBatchPolicy(input) {
|
|
107
|
+
const record = readRecord(input);
|
|
108
|
+
const body = readRecord(record?.policy) ?? readRecord(record?.stream_batching) ?? record;
|
|
109
|
+
return normalizeRelayStreamBatchPolicy(body);
|
|
110
|
+
}
|
|
111
|
+
function normalizeRelayStreamBatchPolicy(input) {
|
|
112
|
+
const record = readRecord(input);
|
|
113
|
+
if (!record) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const flushIntervalMs = readInteger(record.flushIntervalMs ?? record.flush_interval_ms);
|
|
117
|
+
const flushBytes = readInteger(record.flushBytes ?? record.flush_bytes);
|
|
118
|
+
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) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
flushIntervalMs,
|
|
123
|
+
flushBytes
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function readRecord(value) {
|
|
127
|
+
return value && typeof value === "object" ? value : null;
|
|
128
|
+
}
|
|
129
|
+
function readInteger(value) {
|
|
130
|
+
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/relay/control-client.ts
|
|
134
|
+
function connectRelayControl(options) {
|
|
135
|
+
const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
|
|
136
|
+
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
137
|
+
wsUrl.searchParams.set("link_id", options.linkId);
|
|
138
|
+
const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
139
|
+
const backoffBaseMs = options.backoffBaseMs ?? 1e3;
|
|
140
|
+
const backoffMaxMs = options.backoffMaxMs ?? 3e4;
|
|
141
|
+
let reconnectAttempts = 0;
|
|
142
|
+
let closedByUser = false;
|
|
143
|
+
let socket = null;
|
|
144
|
+
let retryTimer = null;
|
|
145
|
+
let abortControllers = /* @__PURE__ */ new Map();
|
|
146
|
+
let fatalRelayRejection = null;
|
|
147
|
+
let latestNetworkRoutes = null;
|
|
148
|
+
const streamBatchPolicy = {
|
|
149
|
+
current: options.initialStreamBatchPolicy ?? DEFAULT_RELAY_STREAM_BATCH_POLICY,
|
|
150
|
+
onUpdate: options.onStreamBatchPolicy
|
|
151
|
+
};
|
|
152
|
+
const connect = () => {
|
|
153
|
+
options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
|
|
154
|
+
fatalRelayRejection = null;
|
|
155
|
+
socket = new WebSocket(wsUrl, {
|
|
156
|
+
headers: {
|
|
157
|
+
"x-hermes-link-version": LINK_VERSION
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
socket.on("open", () => {
|
|
161
|
+
reconnectAttempts = 0;
|
|
162
|
+
options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
|
|
163
|
+
const currentSocket = socket;
|
|
164
|
+
if (currentSocket && latestNetworkRoutes) {
|
|
165
|
+
sendNetworkRoutes(currentSocket, options.linkId, latestNetworkRoutes);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
socket.on("message", (raw) => {
|
|
169
|
+
if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
void handleFrame(socket, String(raw), options.localPort, abortControllers, streamBatchPolicy).catch((error) => {
|
|
173
|
+
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
174
|
+
socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
socket.on("error", (error) => {
|
|
178
|
+
const message = error instanceof Error ? error.message : "Relay websocket error";
|
|
179
|
+
fatalRelayRejection = resolveFatalRelayRejection(message);
|
|
180
|
+
options.onStatus?.({
|
|
181
|
+
state: "disconnected",
|
|
182
|
+
attempt: reconnectAttempts,
|
|
183
|
+
message: fatalRelayRejection ?? message
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
socket.on("close", () => {
|
|
187
|
+
abortAll(abortControllers);
|
|
188
|
+
abortControllers = /* @__PURE__ */ new Map();
|
|
189
|
+
if (fatalRelayRejection) {
|
|
190
|
+
options.onStatus?.({
|
|
191
|
+
state: "failed",
|
|
192
|
+
attempt: reconnectAttempts,
|
|
193
|
+
message: fatalRelayRejection
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (closedByUser) {
|
|
198
|
+
options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
202
|
+
options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
reconnectAttempts += 1;
|
|
206
|
+
const delay = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
|
|
207
|
+
options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay}ms` });
|
|
208
|
+
retryTimer = setTimeout(connect, delay);
|
|
209
|
+
retryTimer.unref?.();
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
connect();
|
|
213
|
+
return {
|
|
214
|
+
publishNetworkRoutes(routes) {
|
|
215
|
+
latestNetworkRoutes = routes;
|
|
216
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
217
|
+
sendNetworkRoutes(socket, options.linkId, routes);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
updateStreamBatchPolicy(policy) {
|
|
221
|
+
streamBatchPolicy.current = policy;
|
|
222
|
+
streamBatchPolicy.onUpdate?.(policy);
|
|
223
|
+
},
|
|
224
|
+
close() {
|
|
225
|
+
closedByUser = true;
|
|
226
|
+
if (retryTimer) {
|
|
227
|
+
clearTimeout(retryTimer);
|
|
228
|
+
retryTimer = null;
|
|
229
|
+
}
|
|
230
|
+
abortAll(abortControllers);
|
|
231
|
+
socket?.terminate();
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function sendNetworkRoutes(socket, linkId, routes) {
|
|
236
|
+
socket.send(JSON.stringify({
|
|
237
|
+
type: "network.routes",
|
|
238
|
+
id: `routes_${Date.now().toString(36)}`,
|
|
239
|
+
payload: {
|
|
240
|
+
link_id: linkId,
|
|
241
|
+
lan_ips: routes.lanIps,
|
|
242
|
+
public_ipv4s: routes.publicIpv4s,
|
|
243
|
+
public_ipv6s: routes.publicIpv6s,
|
|
244
|
+
observed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
245
|
+
}
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
function resolveFatalRelayRejection(message) {
|
|
249
|
+
if (!/Unexpected server response:\s*(400|401|403|426)\b/u.test(message)) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return "Relay refused the Hermes Link connection. Check Link version and pairing state before retrying.";
|
|
253
|
+
}
|
|
254
|
+
function abortAll(abortControllers) {
|
|
255
|
+
for (const controller of abortControllers.values()) {
|
|
256
|
+
controller.abort();
|
|
257
|
+
}
|
|
258
|
+
abortControllers.clear();
|
|
259
|
+
}
|
|
260
|
+
function computeBackoffMs(attempt, baseMs, maxMs) {
|
|
261
|
+
const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
|
|
262
|
+
const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
|
|
263
|
+
return exponential + jitter;
|
|
264
|
+
}
|
|
265
|
+
async function handleFrame(socket, raw, localPort, abortControllers, streamBatchPolicy) {
|
|
266
|
+
const frame = JSON.parse(raw);
|
|
267
|
+
if (frame.type === "relay.config.update") {
|
|
268
|
+
const nextPolicy = readRelayStreamBatchPolicy(frame.payload);
|
|
269
|
+
if (nextPolicy) {
|
|
270
|
+
streamBatchPolicy.current = nextPolicy;
|
|
271
|
+
streamBatchPolicy.onUpdate?.(nextPolicy);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (frame.type === "http.cancel") {
|
|
276
|
+
abortControllers.get(frame.id)?.abort();
|
|
277
|
+
abortControllers.delete(frame.id);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (frame.type !== "http.request") {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const abortController = new AbortController();
|
|
284
|
+
abortControllers.set(frame.id, abortController);
|
|
285
|
+
let sseBatcher = null;
|
|
286
|
+
try {
|
|
287
|
+
const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
|
|
288
|
+
method: frame.method,
|
|
289
|
+
headers: frame.headers ?? {},
|
|
290
|
+
body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
|
|
291
|
+
signal: abortController.signal
|
|
292
|
+
});
|
|
293
|
+
const headers = Object.fromEntries(response.headers.entries());
|
|
294
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
295
|
+
if (response.body && contentType.includes("text/event-stream")) {
|
|
296
|
+
socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
|
|
297
|
+
sseBatcher = createRelayStreamChunkBatcher(socket, frame.id, streamBatchPolicy);
|
|
298
|
+
const reader = response.body.getReader();
|
|
299
|
+
while (true) {
|
|
300
|
+
const next = await reader.read();
|
|
301
|
+
if (next.done) {
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
sseBatcher.push(next.value);
|
|
305
|
+
}
|
|
306
|
+
sseBatcher.flush();
|
|
307
|
+
socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const body = Buffer.from(await response.arrayBuffer()).toString("base64");
|
|
311
|
+
socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (abortController.signal.aborted || isAbortError(error)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
sseBatcher?.flush();
|
|
317
|
+
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
318
|
+
socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
|
|
319
|
+
} finally {
|
|
320
|
+
sseBatcher?.dispose();
|
|
321
|
+
abortControllers.delete(frame.id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function isAbortError(error) {
|
|
325
|
+
return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
|
|
326
|
+
}
|
|
327
|
+
function createRelayStreamChunkBatcher(socket, id, streamBatchPolicy) {
|
|
328
|
+
let chunks = [];
|
|
329
|
+
let totalBytes = 0;
|
|
330
|
+
let flushTimer = null;
|
|
331
|
+
const clearFlushTimer = () => {
|
|
332
|
+
if (flushTimer == null) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
clearTimeout(flushTimer);
|
|
336
|
+
flushTimer = null;
|
|
337
|
+
};
|
|
338
|
+
const flush = () => {
|
|
339
|
+
clearFlushTimer();
|
|
340
|
+
if (totalBytes <= 0) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const bodyBase64 = Buffer.concat(chunks, totalBytes).toString("base64");
|
|
344
|
+
chunks = [];
|
|
345
|
+
totalBytes = 0;
|
|
346
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
socket.send(JSON.stringify({ type: "http.stream.chunk", id, bodyBase64 }));
|
|
350
|
+
};
|
|
351
|
+
const scheduleFlush = () => {
|
|
352
|
+
if (flushTimer != null) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
flushTimer = setTimeout(() => {
|
|
356
|
+
flushTimer = null;
|
|
357
|
+
flush();
|
|
358
|
+
}, streamBatchPolicy.current.flushIntervalMs);
|
|
359
|
+
flushTimer.unref?.();
|
|
360
|
+
};
|
|
361
|
+
return {
|
|
362
|
+
push(chunk) {
|
|
363
|
+
if (chunk.byteLength <= 0) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const buffer = Buffer.from(chunk);
|
|
367
|
+
chunks.push(buffer);
|
|
368
|
+
totalBytes += buffer.byteLength;
|
|
369
|
+
if (totalBytes >= streamBatchPolicy.current.flushBytes) {
|
|
370
|
+
flush();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
scheduleFlush();
|
|
374
|
+
},
|
|
375
|
+
flush,
|
|
376
|
+
dispose() {
|
|
377
|
+
clearFlushTimer();
|
|
378
|
+
chunks = [];
|
|
379
|
+
totalBytes = 0;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// src/link/network-report-state.ts
|
|
385
|
+
var DEFAULT_AUTO_DAILY_LIMIT = 20;
|
|
386
|
+
async function readNetworkReportState(paths) {
|
|
387
|
+
const state = await readLinkState(paths);
|
|
388
|
+
return normalizeNetworkReportState(state.networkReport);
|
|
389
|
+
}
|
|
390
|
+
async function markNetworkStatusReported(paths, snapshotInput, reportedAt = /* @__PURE__ */ new Date()) {
|
|
391
|
+
const snapshot = normalizeNetworkSnapshot(snapshotInput);
|
|
392
|
+
await updateNetworkReportState(paths, (current) => ({
|
|
393
|
+
...current,
|
|
394
|
+
lastReportedLanIps: snapshot.lanIps,
|
|
395
|
+
lastReportedPublicIpv4s: snapshot.publicIpv4s,
|
|
396
|
+
lastReportedPublicIpv6s: snapshot.publicIpv6s,
|
|
397
|
+
lastReportedAt: reportedAt.toISOString(),
|
|
398
|
+
lastAutoAttempt: current.lastAutoAttempt ? { ...current.lastAutoAttempt, ...snapshot, success: true } : null
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
async function reserveAutomaticNetworkReport(paths, snapshotInput, options = {}) {
|
|
402
|
+
const snapshot = normalizeNetworkSnapshot(snapshotInput);
|
|
403
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
404
|
+
const dailyLimit = Math.max(0, Math.floor(options.dailyLimit ?? DEFAULT_AUTO_DAILY_LIMIT));
|
|
405
|
+
let reservation = { allowed: false, reason: "unchanged" };
|
|
406
|
+
await updateNetworkReportState(paths, (current) => {
|
|
407
|
+
if (sameNetworkSnapshot(readReportedSnapshot(current), snapshot)) {
|
|
408
|
+
const lastReportedAt = current.lastReportedAt ? Date.parse(current.lastReportedAt) : Number.NaN;
|
|
409
|
+
const unchangedMinIntervalMs = Math.max(0, Math.floor(options.unchangedMinIntervalMs ?? 0));
|
|
410
|
+
if (!options.force || Number.isFinite(lastReportedAt) && now.getTime() - lastReportedAt < unchangedMinIntervalMs) {
|
|
411
|
+
reservation = { allowed: false, reason: "unchanged" };
|
|
412
|
+
return current;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (current.lastAutoAttempt && !current.lastAutoAttempt.success && sameNetworkSnapshot(readAttemptSnapshot(current.lastAutoAttempt), snapshot)) {
|
|
416
|
+
reservation = { allowed: false, reason: "failed_snapshot_not_retried" };
|
|
417
|
+
return current;
|
|
418
|
+
}
|
|
419
|
+
const quotaDay = formatUtcDay(now);
|
|
420
|
+
const reportsToday = current.autoQuotaDay === quotaDay ? current.autoReportsToday : 0;
|
|
421
|
+
if (reportsToday >= dailyLimit) {
|
|
422
|
+
reservation = { allowed: false, reason: "daily_limit_reached" };
|
|
423
|
+
return current;
|
|
424
|
+
}
|
|
425
|
+
reservation = { allowed: true };
|
|
426
|
+
return {
|
|
427
|
+
...current,
|
|
428
|
+
autoQuotaDay: quotaDay,
|
|
429
|
+
autoReportsToday: reportsToday + 1,
|
|
430
|
+
lastAutoAttempt: {
|
|
431
|
+
...snapshot,
|
|
432
|
+
attemptedAt: now.toISOString(),
|
|
433
|
+
success: false
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
return reservation;
|
|
438
|
+
}
|
|
439
|
+
async function mergeLastReportedPublicRoutes(paths, snapshotInput) {
|
|
440
|
+
const state = await readNetworkReportState(paths);
|
|
441
|
+
return {
|
|
442
|
+
...snapshotInput,
|
|
443
|
+
publicIpv4s: uniqueStrings([
|
|
444
|
+
...snapshotInput.publicIpv4s,
|
|
445
|
+
...state.lastReportedPublicIpv4s
|
|
446
|
+
]).slice(0, 2),
|
|
447
|
+
publicIpv6s: uniqueStrings([
|
|
448
|
+
...snapshotInput.publicIpv6s,
|
|
449
|
+
...state.lastReportedPublicIpv6s
|
|
450
|
+
]).slice(0, 2)
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function updateNetworkReportState(paths, update) {
|
|
454
|
+
const state = await readLinkState(paths);
|
|
455
|
+
const next = {
|
|
456
|
+
...state,
|
|
457
|
+
networkReport: update(normalizeNetworkReportState(state.networkReport))
|
|
458
|
+
};
|
|
459
|
+
await writeJsonFile(paths.stateFile, next);
|
|
460
|
+
}
|
|
461
|
+
async function readLinkState(paths) {
|
|
462
|
+
const state = await readJsonFile(paths.stateFile);
|
|
463
|
+
return state && typeof state === "object" ? state : {};
|
|
464
|
+
}
|
|
465
|
+
function normalizeNetworkReportState(value) {
|
|
466
|
+
const record = value && typeof value === "object" ? value : {};
|
|
467
|
+
return {
|
|
468
|
+
lastReportedLanIps: normalizeLanIps(record.lastReportedLanIps),
|
|
469
|
+
lastReportedPublicIpv4s: normalizeLanIps(record.lastReportedPublicIpv4s),
|
|
470
|
+
lastReportedPublicIpv6s: normalizeLanIps(record.lastReportedPublicIpv6s),
|
|
471
|
+
lastReportedAt: typeof record.lastReportedAt === "string" ? record.lastReportedAt : null,
|
|
472
|
+
autoQuotaDay: typeof record.autoQuotaDay === "string" ? record.autoQuotaDay : null,
|
|
473
|
+
autoReportsToday: Number.isFinite(record.autoReportsToday) ? Math.max(0, Math.floor(record.autoReportsToday ?? 0)) : 0,
|
|
474
|
+
lastAutoAttempt: normalizeAttempt(record.lastAutoAttempt)
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function normalizeAttempt(value) {
|
|
478
|
+
if (!value || typeof value !== "object") {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
const record = value;
|
|
482
|
+
if (typeof record.attemptedAt !== "string") {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
lanIps: normalizeLanIps(record.lanIps),
|
|
487
|
+
publicIpv4s: normalizeLanIps(record.publicIpv4s),
|
|
488
|
+
publicIpv6s: normalizeLanIps(record.publicIpv6s),
|
|
489
|
+
attemptedAt: record.attemptedAt,
|
|
490
|
+
success: record.success === true
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function normalizeNetworkSnapshot(value) {
|
|
494
|
+
if (Array.isArray(value)) {
|
|
495
|
+
return {
|
|
496
|
+
lanIps: normalizeLanIps(value),
|
|
497
|
+
publicIpv4s: [],
|
|
498
|
+
publicIpv6s: []
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const record = value && typeof value === "object" ? value : {};
|
|
502
|
+
return {
|
|
503
|
+
lanIps: normalizeLanIps(record.lanIps),
|
|
504
|
+
publicIpv4s: normalizeLanIps(record.publicIpv4s),
|
|
505
|
+
publicIpv6s: normalizeLanIps(record.publicIpv6s)
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function readReportedSnapshot(state) {
|
|
509
|
+
return {
|
|
510
|
+
lanIps: state.lastReportedLanIps,
|
|
511
|
+
publicIpv4s: state.lastReportedPublicIpv4s,
|
|
512
|
+
publicIpv6s: state.lastReportedPublicIpv6s
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function readAttemptSnapshot(attempt) {
|
|
516
|
+
return {
|
|
517
|
+
lanIps: attempt.lanIps,
|
|
518
|
+
publicIpv4s: attempt.publicIpv4s,
|
|
519
|
+
publicIpv6s: attempt.publicIpv6s
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
function normalizeLanIps(value) {
|
|
523
|
+
if (!Array.isArray(value)) {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
return [
|
|
527
|
+
...new Set(
|
|
528
|
+
value.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim())
|
|
529
|
+
)
|
|
530
|
+
];
|
|
531
|
+
}
|
|
532
|
+
function sameNetworkSnapshot(left, right) {
|
|
533
|
+
return sameStringList(left.lanIps, right.lanIps) && sameStringList(left.publicIpv4s, right.publicIpv4s) && sameStringList(left.publicIpv6s, right.publicIpv6s);
|
|
534
|
+
}
|
|
535
|
+
function sameStringList(left, right) {
|
|
536
|
+
if (left.length !== right.length) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
return left.every((value, index) => value === right[index]);
|
|
540
|
+
}
|
|
541
|
+
function uniqueStrings(values) {
|
|
542
|
+
return [...new Set(values)];
|
|
543
|
+
}
|
|
544
|
+
function formatUtcDay(date) {
|
|
545
|
+
return date.toISOString().slice(0, 10);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/link/server-report.ts
|
|
549
|
+
async function reportLinkStatusToServer(options = {}) {
|
|
550
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
551
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
552
|
+
if (!identity?.link_id) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
const discoveredRoutes = options.routes ?? await discoverRouteCandidates({
|
|
556
|
+
port: config.port,
|
|
557
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
558
|
+
linkId: identity.link_id,
|
|
559
|
+
installId: identity.install_id,
|
|
560
|
+
publicKeyPem: identity.public_key_pem,
|
|
561
|
+
observePublicRoute: true,
|
|
562
|
+
configuredLanHost: config.lanHost,
|
|
563
|
+
fetchImpl: options.fetchImpl
|
|
564
|
+
});
|
|
565
|
+
const routes = await mergeLastReportedPublicRoutes(paths, discoveredRoutes);
|
|
566
|
+
const systemInfo = readLinkSystemInfo();
|
|
567
|
+
const payload = {
|
|
568
|
+
type: "hermes_link_status_report",
|
|
569
|
+
link_id: identity.link_id,
|
|
570
|
+
install_id: identity.install_id,
|
|
571
|
+
link_version: LINK_VERSION,
|
|
572
|
+
display_name: systemInfo.defaultDisplayName,
|
|
573
|
+
platform: systemInfo.platform,
|
|
574
|
+
hostname: systemInfo.hostname ?? void 0,
|
|
575
|
+
lan_ips: routes.lanIps,
|
|
576
|
+
public_ipv4s: routes.publicIpv4s,
|
|
577
|
+
public_ipv6s: routes.publicIpv6s,
|
|
578
|
+
reported_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
579
|
+
};
|
|
580
|
+
const signature = signIdentityPayload(identity, canonicalJson(payload));
|
|
581
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
582
|
+
const response = await fetcher(
|
|
583
|
+
`${config.serverBaseUrl.replace(/\/+$/u, "")}/api/v1/links/${encodeURIComponent(identity.link_id)}/report`,
|
|
584
|
+
{
|
|
585
|
+
method: "POST",
|
|
586
|
+
headers: {
|
|
587
|
+
accept: "application/json",
|
|
588
|
+
"content-type": "application/json"
|
|
589
|
+
},
|
|
590
|
+
body: JSON.stringify({
|
|
591
|
+
...payload,
|
|
592
|
+
public_key_pem: identity.public_key_pem,
|
|
593
|
+
signature
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
);
|
|
597
|
+
const body = await response.json().catch(() => null);
|
|
598
|
+
if (!response.ok || !body) {
|
|
599
|
+
const message = readErrorMessage(body) ?? `HermesPilot Server request failed with HTTP ${response.status}`;
|
|
600
|
+
throw new LinkHttpError(response.status, "server_request_failed", message);
|
|
601
|
+
}
|
|
602
|
+
await markNetworkStatusReported(paths, routes);
|
|
603
|
+
return body;
|
|
604
|
+
}
|
|
605
|
+
function canonicalJson(value) {
|
|
606
|
+
return JSON.stringify(sortJsonValue(value));
|
|
607
|
+
}
|
|
608
|
+
function sortJsonValue(value) {
|
|
609
|
+
if (Array.isArray(value)) {
|
|
610
|
+
return value.map(sortJsonValue);
|
|
611
|
+
}
|
|
612
|
+
if (value && typeof value === "object") {
|
|
613
|
+
const record = value;
|
|
614
|
+
const sorted = {};
|
|
615
|
+
for (const key of Object.keys(record).sort()) {
|
|
616
|
+
sorted[key] = sortJsonValue(record[key]);
|
|
617
|
+
}
|
|
618
|
+
return sorted;
|
|
619
|
+
}
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
function readErrorMessage(payload) {
|
|
623
|
+
if (typeof payload !== "object" || payload === null) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
const error = payload.error;
|
|
627
|
+
if (typeof error !== "object" || error === null) {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
const message = error.message;
|
|
631
|
+
return typeof message === "string" ? message : null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// src/daemon/lan-ip-monitor.ts
|
|
635
|
+
var DEFAULT_INTERVAL_MS = 5 * 6e4;
|
|
636
|
+
var DEFAULT_DAILY_REPORT_LIMIT = 20;
|
|
637
|
+
var DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS = 15 * 6e4;
|
|
638
|
+
function startLanIpMonitor(options) {
|
|
639
|
+
let running = false;
|
|
640
|
+
let closed = false;
|
|
641
|
+
let current = Promise.resolve();
|
|
642
|
+
const check = async (context = {}) => {
|
|
643
|
+
if (running || closed) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
running = true;
|
|
647
|
+
try {
|
|
648
|
+
await checkLanIpChange(options, context);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
void options.logger.warn("lan_ip_monitor_failed", {
|
|
651
|
+
error: error instanceof Error ? error.message : String(error)
|
|
652
|
+
});
|
|
653
|
+
} finally {
|
|
654
|
+
running = false;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
current = check({ forceReport: true, publishToRelay: true, observePublicRoute: true });
|
|
658
|
+
const timer = setInterval(() => {
|
|
659
|
+
current = check({ observePublicRoute: false });
|
|
660
|
+
}, options.intervalMs ?? DEFAULT_INTERVAL_MS);
|
|
661
|
+
timer.unref?.();
|
|
662
|
+
return {
|
|
663
|
+
async refreshPublicRoutes() {
|
|
664
|
+
current = check({ forceReport: true, publishToRelay: true, observePublicRoute: true });
|
|
665
|
+
await current;
|
|
666
|
+
},
|
|
667
|
+
async close() {
|
|
668
|
+
closed = true;
|
|
669
|
+
clearInterval(timer);
|
|
670
|
+
await current.catch(() => void 0);
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
async function checkLanIpChange(options, context = {}) {
|
|
675
|
+
const [identity, config] = await Promise.all([
|
|
676
|
+
loadIdentity(options.paths),
|
|
677
|
+
loadConfig(options.paths)
|
|
678
|
+
]);
|
|
679
|
+
if (!identity?.link_id) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const discoveredRoutes = await discoverRouteCandidates({
|
|
683
|
+
port: config.port,
|
|
684
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
685
|
+
linkId: identity.link_id,
|
|
686
|
+
installId: identity.install_id,
|
|
687
|
+
publicKeyPem: identity.public_key_pem,
|
|
688
|
+
observePublicRoute: context.observePublicRoute === true,
|
|
689
|
+
configuredLanHost: config.lanHost,
|
|
690
|
+
fetchImpl: options.fetchImpl
|
|
691
|
+
});
|
|
692
|
+
const routes = await mergeLastReportedPublicRoutes(options.paths, discoveredRoutes);
|
|
693
|
+
if (context.publishToRelay) {
|
|
694
|
+
options.onNetworkRoutes?.(routes);
|
|
695
|
+
}
|
|
696
|
+
const reservation = await reserveAutomaticNetworkReport(options.paths, routes, {
|
|
697
|
+
dailyLimit: options.dailyReportLimit ?? DEFAULT_DAILY_REPORT_LIMIT,
|
|
698
|
+
force: context.forceReport === true,
|
|
699
|
+
unchangedMinIntervalMs: options.startupReportMinIntervalMs ?? DEFAULT_STARTUP_REPORT_MIN_INTERVAL_MS
|
|
700
|
+
});
|
|
701
|
+
if (!reservation.allowed) {
|
|
702
|
+
const logFields = {
|
|
703
|
+
lan_ips: routes.lanIps,
|
|
704
|
+
public_ipv4s: routes.publicIpv4s,
|
|
705
|
+
public_ipv6s: routes.publicIpv6s,
|
|
706
|
+
reason: reservation.reason
|
|
707
|
+
};
|
|
708
|
+
void options.logger.debug("lan_ip_report_skipped", logFields);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
const result = await reportLinkStatusToServer({
|
|
713
|
+
paths: options.paths,
|
|
714
|
+
fetchImpl: options.fetchImpl,
|
|
715
|
+
routes
|
|
716
|
+
});
|
|
717
|
+
if (result) {
|
|
718
|
+
options.onNetworkRoutes?.(routes);
|
|
719
|
+
void options.logger.info("lan_ip_change_reported", {
|
|
720
|
+
link_id: result.linkId,
|
|
721
|
+
lan_ips: routes.lanIps,
|
|
722
|
+
public_ipv4s: routes.publicIpv4s,
|
|
723
|
+
public_ipv6s: routes.publicIpv6s
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
} catch (error) {
|
|
727
|
+
void options.logger.warn("lan_ip_change_report_failed", {
|
|
728
|
+
lan_ips: routes.lanIps,
|
|
729
|
+
error: error instanceof Error ? error.message : String(error)
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/daemon/scheduler.ts
|
|
735
|
+
function startCronDeliveryScheduler(options) {
|
|
736
|
+
let running = false;
|
|
737
|
+
let current = Promise.resolve();
|
|
738
|
+
const syncCronDeliveries = async () => {
|
|
739
|
+
if (running) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
running = true;
|
|
743
|
+
try {
|
|
744
|
+
await syncHermesLinkCronDeliveries(
|
|
745
|
+
options.paths,
|
|
746
|
+
options.conversations,
|
|
747
|
+
options.logger
|
|
748
|
+
);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
void options.logger.warn("cron_link_delivery_sync_failed", {
|
|
751
|
+
source: "daemon_scheduler",
|
|
752
|
+
error: error instanceof Error ? error.message : String(error)
|
|
753
|
+
});
|
|
754
|
+
} finally {
|
|
755
|
+
running = false;
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
const timer = setInterval(() => {
|
|
759
|
+
current = syncCronDeliveries();
|
|
760
|
+
}, options.intervalMs ?? 3e4);
|
|
761
|
+
timer.unref?.();
|
|
762
|
+
return {
|
|
763
|
+
async close() {
|
|
764
|
+
clearInterval(timer);
|
|
765
|
+
await current.catch(() => void 0);
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function startHermesSessionSyncScheduler(options) {
|
|
770
|
+
let running = false;
|
|
771
|
+
let current = Promise.resolve();
|
|
772
|
+
const syncSessions = async () => {
|
|
773
|
+
if (running) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
running = true;
|
|
777
|
+
try {
|
|
778
|
+
await options.conversations.syncHermesSessions();
|
|
779
|
+
} catch (error) {
|
|
780
|
+
void options.logger.warn("hermes_session_sync_failed", {
|
|
781
|
+
source: "daemon_scheduler",
|
|
782
|
+
error: error instanceof Error ? error.message : String(error)
|
|
783
|
+
});
|
|
784
|
+
} finally {
|
|
785
|
+
running = false;
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
const timer = setInterval(() => {
|
|
789
|
+
current = syncSessions();
|
|
790
|
+
}, options.intervalMs ?? 10 * 60 * 1e3);
|
|
791
|
+
timer.unref?.();
|
|
792
|
+
return {
|
|
793
|
+
async close() {
|
|
794
|
+
clearInterval(timer);
|
|
795
|
+
await current.catch(() => void 0);
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// src/daemon/service.ts
|
|
801
|
+
var DEFAULT_RELAY_READY_TIMEOUT_MS = 2e3;
|
|
802
|
+
var RELAY_RECONNECT_PUBLIC_ROUTE_REFRESH_MIN_INTERVAL_MS = 15 * 6e4;
|
|
803
|
+
async function startLinkService(options = {}) {
|
|
804
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
805
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
806
|
+
const logger = createFileLogger({ paths, minLevel: config.logLevel });
|
|
807
|
+
await logger.info("service_starting", {
|
|
808
|
+
port: config.port,
|
|
809
|
+
mode: identity?.link_id ? "paired" : "local-only"
|
|
810
|
+
});
|
|
811
|
+
const migration = await migrateLinkDatabase(paths);
|
|
812
|
+
if (migration.appliedVersions.length > 0) {
|
|
813
|
+
await logger.info("database_migrated", {
|
|
814
|
+
database_file: migration.databaseFile,
|
|
815
|
+
applied_versions: migration.appliedVersions,
|
|
816
|
+
current_version: migration.currentVersion
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
const conversations = new ConversationService(paths, logger);
|
|
820
|
+
await conversations.rebuildStatisticsIndex();
|
|
821
|
+
let relay = null;
|
|
822
|
+
let lanIpMonitor = null;
|
|
823
|
+
const loadRelayStreamBatchPolicy = async (source) => {
|
|
824
|
+
const streamBatchPolicy = await fetchRelayStreamBatchPolicy(config.serverBaseUrl);
|
|
825
|
+
if (!streamBatchPolicy) {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
relay?.updateStreamBatchPolicy(streamBatchPolicy);
|
|
829
|
+
void logger.info("relay_stream_policy_loaded", {
|
|
830
|
+
source,
|
|
831
|
+
flushIntervalMs: streamBatchPolicy.flushIntervalMs,
|
|
832
|
+
flushBytes: streamBatchPolicy.flushBytes
|
|
833
|
+
});
|
|
834
|
+
return streamBatchPolicy;
|
|
835
|
+
};
|
|
836
|
+
let hermesSessionSync = Promise.resolve();
|
|
837
|
+
const triggerHermesSessionSync = () => {
|
|
838
|
+
hermesSessionSync = Promise.resolve().then(() => conversations.syncHermesSessions()).then(() => void 0).catch((error) => {
|
|
839
|
+
void logger.warn("hermes_session_sync_failed", {
|
|
840
|
+
source: "service_startup",
|
|
841
|
+
error: error instanceof Error ? error.message : String(error)
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
};
|
|
845
|
+
const app = await createApp({
|
|
846
|
+
paths,
|
|
847
|
+
logger,
|
|
848
|
+
conversations,
|
|
849
|
+
onPairingClaimed: async () => {
|
|
850
|
+
triggerHermesSessionSync();
|
|
851
|
+
void loadRelayStreamBatchPolicy("pairing_claimed");
|
|
852
|
+
await options.onPairingClaimed?.();
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
const server = createServer(app.callback());
|
|
856
|
+
try {
|
|
857
|
+
await listenServer(server, config.port);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
await logger.error("service_start_failed", {
|
|
860
|
+
port: config.port,
|
|
861
|
+
link_id: identity?.link_id ?? null,
|
|
862
|
+
error: error instanceof Error ? error.message : String(error)
|
|
863
|
+
});
|
|
864
|
+
await logger.flush();
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
server.on("error", (error) => {
|
|
868
|
+
void logger.error("service_error", {
|
|
869
|
+
port: config.port,
|
|
870
|
+
link_id: identity?.link_id ?? null,
|
|
871
|
+
error: error.message
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
void logger.info("service_started", {
|
|
875
|
+
port: config.port,
|
|
876
|
+
link_id: identity?.link_id ?? null
|
|
877
|
+
});
|
|
878
|
+
triggerHermesSessionSync();
|
|
879
|
+
const scheduler = startCronDeliveryScheduler({
|
|
880
|
+
paths,
|
|
881
|
+
conversations,
|
|
882
|
+
logger
|
|
883
|
+
});
|
|
884
|
+
const hermesSessionSyncScheduler = startHermesSessionSyncScheduler({
|
|
885
|
+
conversations,
|
|
886
|
+
logger
|
|
887
|
+
});
|
|
888
|
+
let hasSeenRelayConnected = false;
|
|
889
|
+
let lastRelayReconnectPublicRouteRefreshAt = 0;
|
|
890
|
+
if (identity?.link_id) {
|
|
891
|
+
let resolveRelayReady = null;
|
|
892
|
+
const relayReady = new Promise((resolve) => {
|
|
893
|
+
resolveRelayReady = resolve;
|
|
894
|
+
});
|
|
895
|
+
relay = connectRelayControl({
|
|
896
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
897
|
+
linkId: identity.link_id,
|
|
898
|
+
localPort: config.port,
|
|
899
|
+
maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
|
|
900
|
+
backoffBaseMs: 1e3,
|
|
901
|
+
backoffMaxMs: 3e4,
|
|
902
|
+
onStreamBatchPolicy: (policy) => {
|
|
903
|
+
void logger.info("relay_stream_policy_updated", {
|
|
904
|
+
flushIntervalMs: policy.flushIntervalMs,
|
|
905
|
+
flushBytes: policy.flushBytes
|
|
906
|
+
});
|
|
907
|
+
},
|
|
908
|
+
onStatus: (status) => {
|
|
909
|
+
void logger.info("relay_status", status);
|
|
910
|
+
if (status.state === "connected") {
|
|
911
|
+
const now = Date.now();
|
|
912
|
+
if (hasSeenRelayConnected && lanIpMonitor && now - lastRelayReconnectPublicRouteRefreshAt >= RELAY_RECONNECT_PUBLIC_ROUTE_REFRESH_MIN_INTERVAL_MS) {
|
|
913
|
+
lastRelayReconnectPublicRouteRefreshAt = now;
|
|
914
|
+
void lanIpMonitor.refreshPublicRoutes();
|
|
915
|
+
}
|
|
916
|
+
hasSeenRelayConnected = true;
|
|
917
|
+
resolveRelayReady?.(true);
|
|
918
|
+
resolveRelayReady = null;
|
|
919
|
+
} else if (status.state === "failed") {
|
|
920
|
+
resolveRelayReady?.(false);
|
|
921
|
+
resolveRelayReady = null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
void loadRelayStreamBatchPolicy("service_startup");
|
|
926
|
+
if (options.waitForRelayReady) {
|
|
927
|
+
await Promise.race([
|
|
928
|
+
relayReady,
|
|
929
|
+
waitForRelayReadyTimeout(options.relayReadyTimeoutMs)
|
|
930
|
+
]);
|
|
931
|
+
resolveRelayReady = null;
|
|
932
|
+
}
|
|
933
|
+
} else {
|
|
934
|
+
void logger.info("relay_skipped", { reason: "link_not_paired" });
|
|
935
|
+
}
|
|
936
|
+
lanIpMonitor = startLanIpMonitor({
|
|
937
|
+
paths,
|
|
938
|
+
logger,
|
|
939
|
+
intervalMs: options.lanIpMonitorIntervalMs,
|
|
940
|
+
dailyReportLimit: options.lanIpMonitorDailyReportLimit,
|
|
941
|
+
fetchImpl: options.lanIpMonitorFetchImpl,
|
|
942
|
+
onNetworkRoutes: (routes) => {
|
|
943
|
+
relay?.publishNetworkRoutes(routes);
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
if (options.writePidFile) {
|
|
947
|
+
await writePidFile(paths);
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
async close() {
|
|
951
|
+
relay?.close();
|
|
952
|
+
await closeServer(server);
|
|
953
|
+
await Promise.all([
|
|
954
|
+
scheduler.close(),
|
|
955
|
+
hermesSessionSyncScheduler.close(),
|
|
956
|
+
lanIpMonitor?.close(),
|
|
957
|
+
hermesSessionSync.catch(() => void 0)
|
|
958
|
+
]);
|
|
959
|
+
await logger.info("service_stopped");
|
|
960
|
+
await logger.flush();
|
|
961
|
+
if (options.writePidFile) {
|
|
962
|
+
await rm(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
function waitForRelayReadyTimeout(timeoutMs) {
|
|
968
|
+
return new Promise((resolve) => {
|
|
969
|
+
const timer = setTimeout(
|
|
970
|
+
() => resolve(false),
|
|
971
|
+
timeoutMs ?? DEFAULT_RELAY_READY_TIMEOUT_MS
|
|
972
|
+
);
|
|
973
|
+
timer.unref?.();
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
function pidFilePath(paths = resolveRuntimePaths()) {
|
|
977
|
+
return `${paths.runDir}/hermeslink.pid`;
|
|
978
|
+
}
|
|
979
|
+
async function writePidFile(paths) {
|
|
980
|
+
await mkdir(paths.runDir, { recursive: true, mode: 448 });
|
|
981
|
+
await writeFile(pidFilePath(paths), `${process.pid}
|
|
982
|
+
`, { mode: 384 });
|
|
983
|
+
}
|
|
984
|
+
async function closeServer(server) {
|
|
985
|
+
await new Promise((resolve, reject) => {
|
|
986
|
+
let settled = false;
|
|
987
|
+
let forceCloseTimer;
|
|
988
|
+
let timeoutTimer;
|
|
989
|
+
const settle = (error) => {
|
|
990
|
+
if (settled) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
settled = true;
|
|
994
|
+
clearTimeout(forceCloseTimer);
|
|
995
|
+
clearTimeout(timeoutTimer);
|
|
996
|
+
if (error) {
|
|
997
|
+
reject(error);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
resolve();
|
|
1001
|
+
};
|
|
1002
|
+
forceCloseTimer = setTimeout(() => {
|
|
1003
|
+
server.closeIdleConnections?.();
|
|
1004
|
+
server.closeAllConnections?.();
|
|
1005
|
+
}, 250);
|
|
1006
|
+
timeoutTimer = setTimeout(() => {
|
|
1007
|
+
server.closeAllConnections?.();
|
|
1008
|
+
settle();
|
|
1009
|
+
}, 5e3);
|
|
1010
|
+
server.close((error) => {
|
|
1011
|
+
if (error) {
|
|
1012
|
+
settle(error);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
settle();
|
|
1016
|
+
});
|
|
1017
|
+
server.closeIdleConnections?.();
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
async function listenServer(server, port) {
|
|
1021
|
+
await new Promise((resolve, reject) => {
|
|
1022
|
+
const cleanup = () => {
|
|
1023
|
+
server.off("error", onError);
|
|
1024
|
+
server.off("listening", onListening);
|
|
1025
|
+
};
|
|
1026
|
+
const onError = (error) => {
|
|
1027
|
+
cleanup();
|
|
1028
|
+
reject(error);
|
|
1029
|
+
};
|
|
1030
|
+
const onListening = () => {
|
|
1031
|
+
cleanup();
|
|
1032
|
+
resolve();
|
|
1033
|
+
};
|
|
1034
|
+
server.once("error", onError);
|
|
1035
|
+
server.once("listening", onListening);
|
|
1036
|
+
server.listen(port);
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/daemon/process.ts
|
|
1041
|
+
async function startDaemonProcess(paths = resolveRuntimePaths()) {
|
|
1042
|
+
const config = await loadConfig(paths);
|
|
1043
|
+
let status = await getDaemonStatus(paths);
|
|
1044
|
+
if (status.running) {
|
|
1045
|
+
const probe = await probeLocalLinkService({ port: config.port, timeoutMs: 500 });
|
|
1046
|
+
if (probe.reachable) {
|
|
1047
|
+
return status;
|
|
1048
|
+
}
|
|
1049
|
+
await stopDaemonProcess(paths);
|
|
1050
|
+
status = await getDaemonStatus(paths);
|
|
1051
|
+
if (status.running) {
|
|
1052
|
+
return status;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
|
|
1056
|
+
await mkdir2(paths.runDir, { recursive: true, mode: 448 });
|
|
1057
|
+
const scriptPath = currentCliScriptPath();
|
|
1058
|
+
const child = spawn(process.execPath, [scriptPath, "daemon-supervisor"], {
|
|
1059
|
+
detached: true,
|
|
1060
|
+
stdio: "ignore",
|
|
1061
|
+
env: process.env
|
|
1062
|
+
});
|
|
1063
|
+
child.unref();
|
|
1064
|
+
for (let index = 0; index < 12; index += 1) {
|
|
1065
|
+
await wait(250);
|
|
1066
|
+
const next = await getDaemonStatus(paths);
|
|
1067
|
+
if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
|
|
1068
|
+
return next;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return await getDaemonStatus(paths);
|
|
1072
|
+
}
|
|
1073
|
+
async function runDaemonSupervisor(paths = resolveRuntimePaths()) {
|
|
1074
|
+
await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
|
|
1075
|
+
const log = createRotatingTextLogWriter({
|
|
1076
|
+
paths,
|
|
1077
|
+
fileName: path.basename(daemonLogFile(paths))
|
|
1078
|
+
});
|
|
1079
|
+
const scriptPath = currentCliScriptPath();
|
|
1080
|
+
const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
|
|
1081
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1082
|
+
env: process.env
|
|
1083
|
+
});
|
|
1084
|
+
const write = (chunk) => {
|
|
1085
|
+
void log.write(chunk);
|
|
1086
|
+
};
|
|
1087
|
+
write(`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor started
|
|
1088
|
+
`);
|
|
1089
|
+
child.stdout?.on("data", write);
|
|
1090
|
+
child.stderr?.on("data", write);
|
|
1091
|
+
const forwardStop = () => {
|
|
1092
|
+
if (child.pid && isProcessAlive(child.pid)) {
|
|
1093
|
+
child.kill("SIGTERM");
|
|
1094
|
+
}
|
|
1095
|
+
};
|
|
1096
|
+
process.once("SIGINT", forwardStop);
|
|
1097
|
+
process.once("SIGTERM", forwardStop);
|
|
1098
|
+
const result = await new Promise((resolve, reject) => {
|
|
1099
|
+
child.once("error", reject);
|
|
1100
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
1101
|
+
}).catch((error) => {
|
|
1102
|
+
write(`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor failed: ${error instanceof Error ? error.message : String(error)}
|
|
1103
|
+
`);
|
|
1104
|
+
return { code: 1, signal: null };
|
|
1105
|
+
});
|
|
1106
|
+
process.off("SIGINT", forwardStop);
|
|
1107
|
+
process.off("SIGTERM", forwardStop);
|
|
1108
|
+
write(
|
|
1109
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor stopped code=${result.code ?? "null"} signal=${result.signal ?? "null"}
|
|
1110
|
+
`
|
|
1111
|
+
);
|
|
1112
|
+
await log.flush();
|
|
1113
|
+
return result.code ?? (result.signal ? 0 : 1);
|
|
1114
|
+
}
|
|
1115
|
+
async function probeLocalLinkService(options) {
|
|
1116
|
+
const unreachable = {
|
|
1117
|
+
reachable: false,
|
|
1118
|
+
reusable: false,
|
|
1119
|
+
linkId: null,
|
|
1120
|
+
version: null
|
|
1121
|
+
};
|
|
1122
|
+
let response;
|
|
1123
|
+
try {
|
|
1124
|
+
response = await fetch(`http://127.0.0.1:${options.port}/api/v1/bootstrap`, {
|
|
1125
|
+
headers: { accept: "application/json" },
|
|
1126
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 1e3)
|
|
1127
|
+
});
|
|
1128
|
+
} catch {
|
|
1129
|
+
return unreachable;
|
|
1130
|
+
}
|
|
1131
|
+
if (!response.ok) {
|
|
1132
|
+
return unreachable;
|
|
1133
|
+
}
|
|
1134
|
+
const payload = await response.json().catch(() => null);
|
|
1135
|
+
if (!payload || payload.api_version !== 1) {
|
|
1136
|
+
return unreachable;
|
|
1137
|
+
}
|
|
1138
|
+
const linkId = typeof payload.link_id === "string" ? payload.link_id : null;
|
|
1139
|
+
return {
|
|
1140
|
+
reachable: true,
|
|
1141
|
+
reusable: options.linkId ? linkId === options.linkId : true,
|
|
1142
|
+
linkId,
|
|
1143
|
+
version: typeof payload.version === "string" ? payload.version : null
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
async function stopDaemonProcess(paths = resolveRuntimePaths()) {
|
|
1147
|
+
const status = await getDaemonStatus(paths);
|
|
1148
|
+
if (!status.running || !status.pid) {
|
|
1149
|
+
return status;
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
process.kill(status.pid, "SIGTERM");
|
|
1153
|
+
} catch {
|
|
1154
|
+
await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
1155
|
+
return await getDaemonStatus(paths);
|
|
1156
|
+
}
|
|
1157
|
+
for (let index = 0; index < 20; index += 1) {
|
|
1158
|
+
await wait(250);
|
|
1159
|
+
if (!isProcessAlive(status.pid)) {
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (isProcessAlive(status.pid)) {
|
|
1164
|
+
try {
|
|
1165
|
+
process.kill(status.pid, "SIGKILL");
|
|
1166
|
+
} catch {
|
|
1167
|
+
}
|
|
1168
|
+
for (let index = 0; index < 10; index += 1) {
|
|
1169
|
+
await wait(250);
|
|
1170
|
+
if (!isProcessAlive(status.pid)) {
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (!isProcessAlive(status.pid) || !await pidBackedServiceIsReachable(paths)) {
|
|
1176
|
+
await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
1177
|
+
}
|
|
1178
|
+
return await getDaemonStatus(paths);
|
|
1179
|
+
}
|
|
1180
|
+
async function getDaemonStatus(paths = resolveRuntimePaths()) {
|
|
1181
|
+
const pidFile = pidFilePath(paths);
|
|
1182
|
+
const pid = await readPid(pidFile);
|
|
1183
|
+
if (pid && !isProcessAlive(pid)) {
|
|
1184
|
+
await rm2(pidFile, { force: true }).catch(() => void 0);
|
|
1185
|
+
return {
|
|
1186
|
+
running: false,
|
|
1187
|
+
pid: null,
|
|
1188
|
+
pidFile,
|
|
1189
|
+
logFile: daemonLogFile(paths)
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
running: Boolean(pid),
|
|
1194
|
+
pid,
|
|
1195
|
+
pidFile,
|
|
1196
|
+
logFile: daemonLogFile(paths)
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
function daemonLogFile(paths = resolveRuntimePaths()) {
|
|
1200
|
+
return getDaemonLogFile(paths);
|
|
1201
|
+
}
|
|
1202
|
+
function currentCliScriptPath() {
|
|
1203
|
+
return process.argv[1];
|
|
1204
|
+
}
|
|
1205
|
+
async function readPid(filePath) {
|
|
1206
|
+
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
1207
|
+
if (!raw) {
|
|
1208
|
+
return null;
|
|
1209
|
+
}
|
|
1210
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
1211
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
1212
|
+
}
|
|
1213
|
+
function isProcessAlive(pid) {
|
|
1214
|
+
try {
|
|
1215
|
+
process.kill(pid, 0);
|
|
1216
|
+
return true;
|
|
1217
|
+
} catch {
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async function pidBackedServiceIsReachable(paths) {
|
|
1222
|
+
const config = await loadConfig(paths).catch(() => null);
|
|
1223
|
+
if (!config) {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
return (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable;
|
|
1227
|
+
}
|
|
1228
|
+
function wait(ms) {
|
|
1229
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/autostart/autostart.ts
|
|
55
1233
|
var execFileAsync = promisify(execFile);
|
|
56
1234
|
var MACOS_LABEL = "com.hermespilot.link";
|
|
57
1235
|
async function enableAutostart() {
|
|
@@ -59,14 +1237,14 @@ async function enableAutostart() {
|
|
|
59
1237
|
if (!definition) {
|
|
60
1238
|
return unsupportedStatus();
|
|
61
1239
|
}
|
|
62
|
-
await
|
|
63
|
-
await
|
|
1240
|
+
await mkdir3(path2.dirname(definition.filePath), { recursive: true, mode: 448 });
|
|
1241
|
+
await writeFile2(definition.filePath, definition.content, { mode: 384 });
|
|
64
1242
|
if (definition.method === "systemd-user") {
|
|
65
|
-
await execFileAsync("systemctl", ["--user", "enable",
|
|
66
|
-
await
|
|
1243
|
+
await execFileAsync("systemctl", ["--user", "enable", path2.basename(definition.filePath)]).catch(async () => {
|
|
1244
|
+
await rm3(definition.filePath, { force: true }).catch(() => void 0);
|
|
67
1245
|
const fallback = xdgAutostartDefinition();
|
|
68
|
-
await
|
|
69
|
-
await
|
|
1246
|
+
await mkdir3(path2.dirname(fallback.filePath), { recursive: true, mode: 448 });
|
|
1247
|
+
await writeFile2(fallback.filePath, fallback.content, { mode: 384 });
|
|
70
1248
|
});
|
|
71
1249
|
}
|
|
72
1250
|
return await getAutostartStatus();
|
|
@@ -75,9 +1253,9 @@ async function disableAutostart() {
|
|
|
75
1253
|
const definitions = await allAutostartDefinitions();
|
|
76
1254
|
for (const definition of definitions) {
|
|
77
1255
|
if (definition.method === "systemd-user") {
|
|
78
|
-
await execFileAsync("systemctl", ["--user", "disable",
|
|
1256
|
+
await execFileAsync("systemctl", ["--user", "disable", path2.basename(definition.filePath)]).catch(() => void 0);
|
|
79
1257
|
}
|
|
80
|
-
await
|
|
1258
|
+
await rm3(definition.filePath, { force: true }).catch(() => void 0);
|
|
81
1259
|
}
|
|
82
1260
|
return await getAutostartStatus();
|
|
83
1261
|
}
|
|
@@ -87,7 +1265,7 @@ async function getAutostartStatus() {
|
|
|
87
1265
|
return unsupportedStatus();
|
|
88
1266
|
}
|
|
89
1267
|
for (const definition of definitions) {
|
|
90
|
-
const content = await
|
|
1268
|
+
const content = await readFile2(definition.filePath, "utf8").catch(() => null);
|
|
91
1269
|
if (content !== null) {
|
|
92
1270
|
return {
|
|
93
1271
|
supported: true,
|
|
@@ -138,7 +1316,8 @@ async function hasSystemctlUser() {
|
|
|
138
1316
|
}
|
|
139
1317
|
}
|
|
140
1318
|
function launchdDefinition() {
|
|
141
|
-
const filePath =
|
|
1319
|
+
const filePath = path2.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
|
|
1320
|
+
const environment = autostartEnvironment();
|
|
142
1321
|
return {
|
|
143
1322
|
method: "launchd",
|
|
144
1323
|
filePath,
|
|
@@ -154,6 +1333,10 @@ function launchdDefinition() {
|
|
|
154
1333
|
<string>${xmlEscape(currentCliScriptPath())}</string>
|
|
155
1334
|
<string>daemon-supervisor</string>
|
|
156
1335
|
</array>
|
|
1336
|
+
<key>EnvironmentVariables</key>
|
|
1337
|
+
<dict>
|
|
1338
|
+
${plistEnvironmentEntries(environment)}
|
|
1339
|
+
</dict>
|
|
157
1340
|
<key>RunAtLoad</key>
|
|
158
1341
|
<true/>
|
|
159
1342
|
<key>KeepAlive</key>
|
|
@@ -164,7 +1347,8 @@ function launchdDefinition() {
|
|
|
164
1347
|
};
|
|
165
1348
|
}
|
|
166
1349
|
function systemdUserDefinition() {
|
|
167
|
-
const filePath =
|
|
1350
|
+
const filePath = path2.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
|
|
1351
|
+
const environment = autostartEnvironment();
|
|
168
1352
|
return {
|
|
169
1353
|
method: "systemd-user",
|
|
170
1354
|
filePath,
|
|
@@ -174,6 +1358,7 @@ After=network-online.target
|
|
|
174
1358
|
|
|
175
1359
|
[Service]
|
|
176
1360
|
Type=simple
|
|
1361
|
+
${systemdEnvironmentLines(environment)}
|
|
177
1362
|
ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon-supervisor
|
|
178
1363
|
Restart=no
|
|
179
1364
|
|
|
@@ -183,28 +1368,33 @@ WantedBy=default.target
|
|
|
183
1368
|
};
|
|
184
1369
|
}
|
|
185
1370
|
function xdgAutostartDefinition() {
|
|
186
|
-
const filePath =
|
|
1371
|
+
const filePath = path2.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
|
|
1372
|
+
const environment = autostartEnvironment();
|
|
187
1373
|
return {
|
|
188
1374
|
method: "xdg-autostart",
|
|
189
1375
|
filePath,
|
|
190
1376
|
content: `[Desktop Entry]
|
|
191
1377
|
Type=Application
|
|
192
1378
|
Name=Hermes Link
|
|
193
|
-
Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon-supervisor
|
|
1379
|
+
Exec=${desktopQuote("/usr/bin/env")} ${desktopEnvironmentArgs(environment)} ${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon-supervisor
|
|
194
1380
|
Terminal=false
|
|
195
1381
|
X-GNOME-Autostart-enabled=true
|
|
196
1382
|
`
|
|
197
1383
|
};
|
|
198
1384
|
}
|
|
199
1385
|
function windowsStartupDefinition() {
|
|
200
|
-
const appData = process.env.APPDATA ??
|
|
201
|
-
const filePath =
|
|
1386
|
+
const appData = process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming");
|
|
1387
|
+
const filePath = path2.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
|
|
1388
|
+
const environment = autostartEnvironment();
|
|
202
1389
|
return {
|
|
203
1390
|
method: "windows-startup",
|
|
204
1391
|
filePath,
|
|
205
|
-
content:
|
|
206
|
-
|
|
207
|
-
|
|
1392
|
+
content: [
|
|
1393
|
+
"@echo off",
|
|
1394
|
+
...cmdEnvironmentLines(environment),
|
|
1395
|
+
`start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon-supervisor`,
|
|
1396
|
+
""
|
|
1397
|
+
].join("\r\n")
|
|
208
1398
|
};
|
|
209
1399
|
}
|
|
210
1400
|
function unsupportedStatus() {
|
|
@@ -224,6 +1414,57 @@ function systemdQuote(value) {
|
|
|
224
1414
|
function desktopQuote(value) {
|
|
225
1415
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
226
1416
|
}
|
|
1417
|
+
function autostartEnvironment() {
|
|
1418
|
+
const environment = {};
|
|
1419
|
+
const pathValue = buildAutostartPath();
|
|
1420
|
+
if (pathValue) {
|
|
1421
|
+
environment.PATH = pathValue;
|
|
1422
|
+
}
|
|
1423
|
+
const hermesBin = process.env.HERMES_BIN?.trim();
|
|
1424
|
+
if (hermesBin) {
|
|
1425
|
+
environment.HERMES_BIN = hermesBin;
|
|
1426
|
+
}
|
|
1427
|
+
return environment;
|
|
1428
|
+
}
|
|
1429
|
+
function buildAutostartPath() {
|
|
1430
|
+
const separator = process.platform === "win32" ? ";" : ":";
|
|
1431
|
+
const candidates = [
|
|
1432
|
+
path2.dirname(process.execPath),
|
|
1433
|
+
...(process.env.PATH ?? "").split(separator),
|
|
1434
|
+
path2.join(os.homedir(), ".local", "bin"),
|
|
1435
|
+
...process.platform === "win32" ? [] : ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
|
1436
|
+
];
|
|
1437
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1438
|
+
const entries = [];
|
|
1439
|
+
for (const candidate of candidates) {
|
|
1440
|
+
const normalized = candidate.trim();
|
|
1441
|
+
if (!normalized) {
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
const key = process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
1445
|
+
if (seen.has(key)) {
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
seen.add(key);
|
|
1449
|
+
entries.push(normalized);
|
|
1450
|
+
}
|
|
1451
|
+
return entries.join(separator);
|
|
1452
|
+
}
|
|
1453
|
+
function plistEnvironmentEntries(environment) {
|
|
1454
|
+
return Object.entries(environment).map(([key, value]) => ` <key>${xmlEscape(key)}</key>
|
|
1455
|
+
<string>${xmlEscape(value)}</string>`).join("\n");
|
|
1456
|
+
}
|
|
1457
|
+
function systemdEnvironmentLines(environment) {
|
|
1458
|
+
return Object.entries(environment).map(([key, value]) => `Environment=${systemdQuote(`${key}=${value}`)}`).join("\n");
|
|
1459
|
+
}
|
|
1460
|
+
function desktopEnvironmentArgs(environment) {
|
|
1461
|
+
return Object.entries(environment).map(([key, value]) => desktopQuote(`${key}=${value}`)).join(" ");
|
|
1462
|
+
}
|
|
1463
|
+
function cmdEnvironmentLines(environment) {
|
|
1464
|
+
return Object.entries(environment).map(
|
|
1465
|
+
([key, value]) => `set "${key}=${value.replaceAll('"', "")}"`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
227
1468
|
|
|
228
1469
|
// src/i18n.ts
|
|
229
1470
|
var messages = {
|
|
@@ -303,6 +1544,25 @@ var messages = {
|
|
|
303
1544
|
"pair.autostartUnchanged": "Existing paired devices found. Boot autostart settings were left unchanged.",
|
|
304
1545
|
"pair.autostartFailed": "Pairing succeeded, but boot autostart could not be enabled: {message}",
|
|
305
1546
|
"doctor.description": "Run local diagnostics",
|
|
1547
|
+
"doctor.installOnly": "only check npm global command and PATH setup",
|
|
1548
|
+
"doctor.installHeader": "Install/PATH diagnostics:",
|
|
1549
|
+
"doctor.installNpmPrefix": "npm global prefix: {value}",
|
|
1550
|
+
"doctor.installGlobalBin": "npm global bin directory: {value}",
|
|
1551
|
+
"doctor.installExpectedCommand": "expected hermeslink command: {value} ({state})",
|
|
1552
|
+
"doctor.installCommandOnPath": "hermeslink found on PATH: {value}",
|
|
1553
|
+
"doctor.installUnknown": "unknown",
|
|
1554
|
+
"doctor.installOk": "ok",
|
|
1555
|
+
"doctor.installMissing": "missing",
|
|
1556
|
+
"doctor.installReady": "The global hermeslink command is visible to this shell.",
|
|
1557
|
+
"doctor.installPrefixUnavailable": "Could not read npm global prefix with `{command} prefix -g`. Check that npm is available in this shell.",
|
|
1558
|
+
"doctor.installExpectedMissing": "npm does not appear to have created the global hermeslink shim. Re-run `npm install -g @hermespilot/link` in the same system environment where Hermes runs.",
|
|
1559
|
+
"doctor.installPathMissing": "npm installed hermeslink, but this shell cannot see npm's global bin directory on PATH.",
|
|
1560
|
+
"doctor.installShadowed": "A different hermeslink command appears earlier on PATH. Remove the old command or move npm's global bin directory earlier on PATH.",
|
|
1561
|
+
"doctor.installWslWindowsNpm": "This looks like Windows Node/npm being used from WSL (`/mnt/c/...`). Prefer installing Linux Node.js inside WSL, or run both Hermes Agent and Hermes Link on the Windows host.",
|
|
1562
|
+
"doctor.installDirectRun": "Direct run without changing PATH: {command}",
|
|
1563
|
+
"doctor.installUnixPathHint": 'Temporary PATH fix: export PATH="{path}:$PATH"',
|
|
1564
|
+
"doctor.installWindowsPathHint": "Add this directory to your user Path, then open a new terminal: {path}",
|
|
1565
|
+
"doctor.installNpxFallback": "PATH-independent fallback: npx --yes @hermespilot/link doctor --install",
|
|
306
1566
|
"doctor.identityOk": "Runtime identity: OK",
|
|
307
1567
|
"doctor.installId": "Install ID: {value}",
|
|
308
1568
|
"doctor.linkId": "Link ID: {value}",
|
|
@@ -401,6 +1661,25 @@ var messages = {
|
|
|
401
1661
|
"pair.autostartUnchanged": "\u68C0\u6D4B\u5230\u5DF2\u6709\u914D\u5BF9\u8BBE\u5907\uFF0C\u5F00\u673A\u81EA\u542F\u8BBE\u7F6E\u4FDD\u6301\u4E0D\u53D8\u3002",
|
|
402
1662
|
"pair.autostartFailed": "\u914D\u5BF9\u5DF2\u6210\u529F\uFF0C\u4F46\u542F\u7528\u5F00\u673A\u81EA\u542F\u5931\u8D25\uFF1A{message}",
|
|
403
1663
|
"doctor.description": "\u8FD0\u884C\u672C\u673A\u8BCA\u65AD",
|
|
1664
|
+
"doctor.installOnly": "\u53EA\u68C0\u67E5 npm \u5168\u5C40\u547D\u4EE4\u548C PATH \u8BBE\u7F6E",
|
|
1665
|
+
"doctor.installHeader": "\u5B89\u88C5 / PATH \u8BCA\u65AD\uFF1A",
|
|
1666
|
+
"doctor.installNpmPrefix": "npm \u5168\u5C40 prefix\uFF1A{value}",
|
|
1667
|
+
"doctor.installGlobalBin": "npm \u5168\u5C40 bin \u76EE\u5F55\uFF1A{value}",
|
|
1668
|
+
"doctor.installExpectedCommand": "\u9884\u671F\u7684 hermeslink \u547D\u4EE4\uFF1A{value}\uFF08{state}\uFF09",
|
|
1669
|
+
"doctor.installCommandOnPath": "\u5F53\u524D PATH \u627E\u5230\u7684 hermeslink\uFF1A{value}",
|
|
1670
|
+
"doctor.installUnknown": "\u672A\u77E5",
|
|
1671
|
+
"doctor.installOk": "\u5B58\u5728",
|
|
1672
|
+
"doctor.installMissing": "\u672A\u627E\u5230",
|
|
1673
|
+
"doctor.installReady": "\u5F53\u524D shell \u53EF\u4EE5\u76F4\u63A5\u627E\u5230\u5168\u5C40 hermeslink \u547D\u4EE4\u3002",
|
|
1674
|
+
"doctor.installPrefixUnavailable": "\u65E0\u6CD5\u901A\u8FC7 `{command} prefix -g` \u8BFB\u53D6 npm \u5168\u5C40 prefix\u3002\u8BF7\u5148\u786E\u8BA4\u5F53\u524D shell \u80FD\u8FD0\u884C npm\u3002",
|
|
1675
|
+
"doctor.installExpectedMissing": "npm \u4F3C\u4E4E\u6CA1\u6709\u521B\u5EFA\u5168\u5C40 hermeslink shim\u3002\u8BF7\u5728 Hermes \u6240\u5728\u7684\u540C\u4E00\u7CFB\u7EDF\u73AF\u5883\u91CC\u91CD\u65B0\u6267\u884C `npm install -g @hermespilot/link`\u3002",
|
|
1676
|
+
"doctor.installPathMissing": "npm \u5DF2\u5B89\u88C5 hermeslink\uFF0C\u4F46\u5F53\u524D shell \u7684 PATH \u91CC\u6CA1\u6709 npm \u5168\u5C40 bin \u76EE\u5F55\u3002",
|
|
1677
|
+
"doctor.installShadowed": "PATH \u524D\u9762\u5B58\u5728\u53E6\u4E00\u4E2A hermeslink \u547D\u4EE4\u3002\u8BF7\u5220\u9664\u65E7\u547D\u4EE4\uFF0C\u6216\u628A npm \u5168\u5C40 bin \u76EE\u5F55\u653E\u5230 PATH \u66F4\u524D\u9762\u3002",
|
|
1678
|
+
"doctor.installWslWindowsNpm": "\u5F53\u524D\u770B\u8D77\u6765\u662F\u5728 WSL \u4E2D\u8C03\u7528\u4E86 Windows Node/npm\uFF08`/mnt/c/...`\uFF09\u3002\u5EFA\u8BAE\u5728 WSL \u5185\u5B89\u88C5 Linux \u7248 Node.js\uFF0C\u6216\u628A Hermes Agent \u4E0E Hermes Link \u90FD\u653E\u5728 Windows \u5BBF\u4E3B\u673A\u8FD0\u884C\u3002",
|
|
1679
|
+
"doctor.installDirectRun": "\u4E0D\u6539 PATH \u4E5F\u53EF\u4EE5\u76F4\u63A5\u8FD0\u884C\uFF1A{command}",
|
|
1680
|
+
"doctor.installUnixPathHint": '\u4E34\u65F6\u8865 PATH\uFF1Aexport PATH="{path}:$PATH"',
|
|
1681
|
+
"doctor.installWindowsPathHint": "\u628A\u8FD9\u4E2A\u76EE\u5F55\u52A0\u5165\u5F53\u524D\u7528\u6237\u7684 Path\uFF0C\u7136\u540E\u91CD\u65B0\u6253\u5F00\u7EC8\u7AEF\uFF1A{path}",
|
|
1682
|
+
"doctor.installNpxFallback": "\u4E0D\u4F9D\u8D56 PATH \u7684\u515C\u5E95\u547D\u4EE4\uFF1Anpx --yes @hermespilot/link doctor --install",
|
|
404
1683
|
"doctor.identityOk": "\u8FD0\u884C\u8EAB\u4EFD\uFF1A\u6B63\u5E38",
|
|
405
1684
|
"doctor.installId": "Install ID\uFF1A{value}",
|
|
406
1685
|
"doctor.linkId": "Link ID\uFF1A{value}",
|
|
@@ -515,12 +1794,12 @@ function parseLanguage(value) {
|
|
|
515
1794
|
|
|
516
1795
|
// src/pairing/preflight.ts
|
|
517
1796
|
import { access, stat } from "fs/promises";
|
|
518
|
-
import
|
|
1797
|
+
import path3 from "path";
|
|
519
1798
|
async function assertPairingPreflightReady(options = {}) {
|
|
520
1799
|
const profileName = normalizeProfileName(options.profileName);
|
|
521
1800
|
const hermesHome = resolveHermesProfileDir(profileName);
|
|
522
1801
|
const configPath = resolveHermesConfigPath(profileName);
|
|
523
|
-
const envPath =
|
|
1802
|
+
const envPath = path3.join(hermesHome, ".env");
|
|
524
1803
|
const failures = [];
|
|
525
1804
|
options.onProgress?.("hermes_files");
|
|
526
1805
|
if (!await isDirectory(hermesHome)) {
|
|
@@ -664,7 +1943,7 @@ function normalizeProfileName(profileName) {
|
|
|
664
1943
|
}
|
|
665
1944
|
|
|
666
1945
|
// src/runtime/browser.ts
|
|
667
|
-
import { spawn } from "child_process";
|
|
1946
|
+
import { spawn as spawn2 } from "child_process";
|
|
668
1947
|
async function openSystemBrowser(url) {
|
|
669
1948
|
const platform = process.platform;
|
|
670
1949
|
if (platform === "win32") {
|
|
@@ -686,7 +1965,7 @@ async function spawnDetached(command, args) {
|
|
|
686
1965
|
resolve(ok);
|
|
687
1966
|
};
|
|
688
1967
|
try {
|
|
689
|
-
const child =
|
|
1968
|
+
const child = spawn2(command, args, {
|
|
690
1969
|
detached: true,
|
|
691
1970
|
stdio: "ignore"
|
|
692
1971
|
});
|
|
@@ -701,6 +1980,135 @@ async function spawnDetached(command, args) {
|
|
|
701
1980
|
});
|
|
702
1981
|
}
|
|
703
1982
|
|
|
1983
|
+
// src/runtime/install-info.ts
|
|
1984
|
+
import { execFileSync } from "child_process";
|
|
1985
|
+
import { existsSync, realpathSync } from "fs";
|
|
1986
|
+
import path4 from "path";
|
|
1987
|
+
function readInstallPathInfo() {
|
|
1988
|
+
return inspectInstallPath({
|
|
1989
|
+
npmPrefix: resolveGlobalPrefix()
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
function inspectInstallPath(options = {}) {
|
|
1993
|
+
const platform = options.platform ?? process.platform;
|
|
1994
|
+
const env = options.env ?? process.env;
|
|
1995
|
+
const exists = options.exists ?? existsSync;
|
|
1996
|
+
const realpath = options.realpath ?? realpathSync;
|
|
1997
|
+
const npmPrefix = options.npmPrefix === void 0 ? resolveGlobalPrefix(platform, env) : options.npmPrefix;
|
|
1998
|
+
const globalBinDir = npmPrefix ? resolveGlobalBinDir(npmPrefix, platform) : null;
|
|
1999
|
+
const expectedCommandPath = globalBinDir ? resolveGlobalCommandPath(globalBinDir, platform) : null;
|
|
2000
|
+
const expectedCommandExists = expectedCommandPath ? exists(expectedCommandPath) : false;
|
|
2001
|
+
const pathIncludesGlobalBin = globalBinDir ? pathIncludes(globalBinDir, env.PATH ?? "", platform) : false;
|
|
2002
|
+
const commandOnPath = findCommandOnPath(LINK_COMMAND, env, platform, exists);
|
|
2003
|
+
const commandOnPathMatchesExpected = commandOnPath && expectedCommandPath ? samePath(commandOnPath, expectedCommandPath, platform, realpath) : null;
|
|
2004
|
+
return {
|
|
2005
|
+
platform,
|
|
2006
|
+
npmCommand: resolveNpmCommand(platform),
|
|
2007
|
+
npmPrefix,
|
|
2008
|
+
globalBinDir,
|
|
2009
|
+
expectedCommandPath,
|
|
2010
|
+
expectedCommandExists,
|
|
2011
|
+
pathIncludesGlobalBin,
|
|
2012
|
+
commandOnPath,
|
|
2013
|
+
commandOnPathMatchesExpected,
|
|
2014
|
+
wslWindowsNpmLikely: platform === "linux" && Boolean(npmPrefix?.startsWith("/mnt/c/"))
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
function hasInstallPathIssue(info) {
|
|
2018
|
+
return !info.npmPrefix || !info.globalBinDir || !info.expectedCommandExists || !info.pathIncludesGlobalBin || !info.commandOnPath || info.commandOnPathMatchesExpected === false || info.wslWindowsNpmLikely;
|
|
2019
|
+
}
|
|
2020
|
+
function resolveGlobalBinDir(prefix, platform) {
|
|
2021
|
+
return platform === "win32" ? prefix : pathForPlatform(platform).join(prefix, "bin");
|
|
2022
|
+
}
|
|
2023
|
+
function resolveGlobalCommandPath(binDir, platform) {
|
|
2024
|
+
return pathForPlatform(platform).join(
|
|
2025
|
+
binDir,
|
|
2026
|
+
platform === "win32" ? `${LINK_COMMAND}.cmd` : LINK_COMMAND
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
function formatShellCommand(commandPath, args, platform) {
|
|
2030
|
+
return [quoteShellToken(commandPath, platform), ...args.map((arg) => quoteShellToken(arg, platform))].join(" ");
|
|
2031
|
+
}
|
|
2032
|
+
function resolveGlobalPrefix(platform = process.platform, env = process.env) {
|
|
2033
|
+
const envPrefix = env.npm_config_prefix?.trim() ?? env.npm_config_global_prefix?.trim() ?? "";
|
|
2034
|
+
if (envPrefix) {
|
|
2035
|
+
return envPrefix;
|
|
2036
|
+
}
|
|
2037
|
+
try {
|
|
2038
|
+
const output = execFileSync(resolveNpmCommand(platform), ["prefix", "-g"], {
|
|
2039
|
+
encoding: "utf8",
|
|
2040
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2041
|
+
timeout: 2e3
|
|
2042
|
+
});
|
|
2043
|
+
return output.trim() || null;
|
|
2044
|
+
} catch {
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
function resolveNpmCommand(platform) {
|
|
2049
|
+
return platform === "win32" ? "npm.cmd" : "npm";
|
|
2050
|
+
}
|
|
2051
|
+
function pathIncludes(target, pathValue, platform) {
|
|
2052
|
+
const normalizedTarget = normalizePath(target, platform);
|
|
2053
|
+
return splitPath(pathValue, platform).some(
|
|
2054
|
+
(entry) => normalizePath(entry, platform) === normalizedTarget
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
function findCommandOnPath(command, env, platform, exists) {
|
|
2058
|
+
for (const entry of splitPath(env.PATH ?? "", platform)) {
|
|
2059
|
+
for (const fileName of commandFileNames(command, env, platform)) {
|
|
2060
|
+
const candidate = pathForPlatform(platform).join(entry, fileName);
|
|
2061
|
+
if (exists(candidate)) {
|
|
2062
|
+
return candidate;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
return null;
|
|
2067
|
+
}
|
|
2068
|
+
function commandFileNames(command, env, platform) {
|
|
2069
|
+
if (platform !== "win32") {
|
|
2070
|
+
return [command];
|
|
2071
|
+
}
|
|
2072
|
+
const extensions = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD;.PS1").split(";").map((value) => value.trim()).filter(Boolean);
|
|
2073
|
+
return [command, ...extensions.map((extension) => `${command}${extension.toLowerCase()}`)];
|
|
2074
|
+
}
|
|
2075
|
+
function samePath(left, right, platform, realpath) {
|
|
2076
|
+
try {
|
|
2077
|
+
return normalizeComparablePath(realpath(left), platform) === normalizeComparablePath(realpath(right), platform);
|
|
2078
|
+
} catch {
|
|
2079
|
+
return normalizeComparablePath(left, platform) === normalizeComparablePath(right, platform);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
function splitPath(pathValue, platform) {
|
|
2083
|
+
const delimiter = platform === "win32" ? ";" : ":";
|
|
2084
|
+
return pathValue.split(delimiter).map((entry) => stripPathQuotes(entry.trim())).filter(Boolean);
|
|
2085
|
+
}
|
|
2086
|
+
function stripPathQuotes(value) {
|
|
2087
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
2088
|
+
return value.slice(1, -1);
|
|
2089
|
+
}
|
|
2090
|
+
return value;
|
|
2091
|
+
}
|
|
2092
|
+
function normalizePath(value, platform) {
|
|
2093
|
+
return normalizeComparablePath(pathForPlatform(platform).resolve(value), platform);
|
|
2094
|
+
}
|
|
2095
|
+
function normalizeComparablePath(value, platform) {
|
|
2096
|
+
const normalized = pathForPlatform(platform).normalize(value);
|
|
2097
|
+
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
2098
|
+
}
|
|
2099
|
+
function pathForPlatform(platform) {
|
|
2100
|
+
return platform === "win32" ? path4.win32 : path4.posix;
|
|
2101
|
+
}
|
|
2102
|
+
function quoteShellToken(value, platform) {
|
|
2103
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) {
|
|
2104
|
+
return value;
|
|
2105
|
+
}
|
|
2106
|
+
if (platform === "win32") {
|
|
2107
|
+
return `"${value.replaceAll('"', '\\"')}"`;
|
|
2108
|
+
}
|
|
2109
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
2110
|
+
}
|
|
2111
|
+
|
|
704
2112
|
// src/cli/index.ts
|
|
705
2113
|
var program = new Command();
|
|
706
2114
|
var helpLanguage = detectSystemLanguage();
|
|
@@ -874,6 +2282,7 @@ program.command("pair").description(helpText("pair.description")).action(async (
|
|
|
874
2282
|
const hadActiveDevices = await hasActiveDevices(paths);
|
|
875
2283
|
const probeBeforePair = await probeLocalLinkService({ port: config.port });
|
|
876
2284
|
const prepared = await preparePairing(paths);
|
|
2285
|
+
const streamBatchPolicy = await fetchRelayStreamBatchPolicy(config.serverBaseUrl);
|
|
877
2286
|
await clearPairingClaim(prepared.sessionId, paths);
|
|
878
2287
|
const probe = await probeLocalLinkService({ port: config.port, linkId: prepared.linkId });
|
|
879
2288
|
if (probe.reachable && !probe.reusable) {
|
|
@@ -892,7 +2301,8 @@ program.command("pair").description(helpText("pair.description")).action(async (
|
|
|
892
2301
|
pairingRelayBridge = connectRelayControl({
|
|
893
2302
|
relayBaseUrl: prepared.relayBaseUrl,
|
|
894
2303
|
linkId: prepared.linkId,
|
|
895
|
-
localPort: config.port
|
|
2304
|
+
localPort: config.port,
|
|
2305
|
+
initialStreamBatchPolicy: streamBatchPolicy
|
|
896
2306
|
});
|
|
897
2307
|
pairingRelayBridge.publishNetworkRoutes(prepared.routes);
|
|
898
2308
|
}
|
|
@@ -999,7 +2409,17 @@ program.command("logs").description(helpText("logs.description")).action(async (
|
|
|
999
2409
|
console.log(t("logs.servicePath", { path: getLinkLogFile(paths) }));
|
|
1000
2410
|
console.log(t("logs.daemonPath", { path: daemonLogFile(paths) }));
|
|
1001
2411
|
});
|
|
1002
|
-
program.command("doctor").description(helpText("doctor.description")).action(async () => {
|
|
2412
|
+
program.command("doctor").option("--install", helpText("doctor.installOnly")).description(helpText("doctor.description")).action(async (options) => {
|
|
2413
|
+
const installInfo = readInstallPathInfo();
|
|
2414
|
+
const installLanguage = await loadCliLanguage().catch(() => detectSystemLanguage());
|
|
2415
|
+
const installT = translate.bind(null, installLanguage);
|
|
2416
|
+
if (options.install) {
|
|
2417
|
+
printInstallDiagnostics(installInfo, installT, true);
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
if (hasInstallPathIssue(installInfo)) {
|
|
2421
|
+
printInstallDiagnostics(installInfo, installT, false);
|
|
2422
|
+
}
|
|
1003
2423
|
const [identity, config] = await Promise.all([ensureIdentity(), loadConfig()]);
|
|
1004
2424
|
const language = resolveLanguage(config.language);
|
|
1005
2425
|
const t = translate.bind(null, language);
|
|
@@ -1045,6 +2465,53 @@ async function loadCliLanguage() {
|
|
|
1045
2465
|
function formatHermesVersion(version) {
|
|
1046
2466
|
return version.version ?? version.raw;
|
|
1047
2467
|
}
|
|
2468
|
+
function printInstallDiagnostics(info, t, verbose) {
|
|
2469
|
+
if (!verbose && !hasInstallPathIssue(info)) {
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
console.log(t("doctor.installHeader"));
|
|
2473
|
+
console.log(t("doctor.installNpmPrefix", { value: info.npmPrefix ?? t("doctor.installUnknown") }));
|
|
2474
|
+
console.log(t("doctor.installGlobalBin", { value: info.globalBinDir ?? t("doctor.installUnknown") }));
|
|
2475
|
+
console.log(
|
|
2476
|
+
t("doctor.installExpectedCommand", {
|
|
2477
|
+
value: info.expectedCommandPath ?? t("doctor.installUnknown"),
|
|
2478
|
+
state: info.expectedCommandExists ? t("doctor.installOk") : t("doctor.installMissing")
|
|
2479
|
+
})
|
|
2480
|
+
);
|
|
2481
|
+
console.log(
|
|
2482
|
+
t("doctor.installCommandOnPath", {
|
|
2483
|
+
value: info.commandOnPath ?? t("doctor.installMissing")
|
|
2484
|
+
})
|
|
2485
|
+
);
|
|
2486
|
+
if (!info.npmPrefix) {
|
|
2487
|
+
console.log(t("doctor.installPrefixUnavailable", { command: info.npmCommand }));
|
|
2488
|
+
} else if (!info.expectedCommandExists) {
|
|
2489
|
+
console.log(t("doctor.installExpectedMissing"));
|
|
2490
|
+
} else if (!info.pathIncludesGlobalBin || !info.commandOnPath) {
|
|
2491
|
+
console.log(t("doctor.installPathMissing"));
|
|
2492
|
+
} else if (info.commandOnPathMatchesExpected === false) {
|
|
2493
|
+
console.log(t("doctor.installShadowed"));
|
|
2494
|
+
} else {
|
|
2495
|
+
console.log(t("doctor.installReady"));
|
|
2496
|
+
}
|
|
2497
|
+
if (info.wslWindowsNpmLikely) {
|
|
2498
|
+
console.log(t("doctor.installWslWindowsNpm"));
|
|
2499
|
+
}
|
|
2500
|
+
if (info.expectedCommandPath && info.expectedCommandExists) {
|
|
2501
|
+
console.log(
|
|
2502
|
+
t("doctor.installDirectRun", {
|
|
2503
|
+
command: formatShellCommand(info.expectedCommandPath, ["doctor"], info.platform)
|
|
2504
|
+
})
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
2507
|
+
if (info.globalBinDir && info.platform !== "win32") {
|
|
2508
|
+
console.log(t("doctor.installUnixPathHint", { path: info.globalBinDir }));
|
|
2509
|
+
}
|
|
2510
|
+
if (info.globalBinDir && info.platform === "win32") {
|
|
2511
|
+
console.log(t("doctor.installWindowsPathHint", { path: info.globalBinDir }));
|
|
2512
|
+
}
|
|
2513
|
+
console.log(t("doctor.installNpxFallback"));
|
|
2514
|
+
}
|
|
1048
2515
|
function pairingPreflightProgressKey(stage) {
|
|
1049
2516
|
switch (stage) {
|
|
1050
2517
|
case "hermes_files":
|
|
@@ -1193,9 +2660,9 @@ function isCliEntrypoint(entry = process.argv[1], moduleUrl = import.meta.url) {
|
|
|
1193
2660
|
return false;
|
|
1194
2661
|
}
|
|
1195
2662
|
try {
|
|
1196
|
-
return moduleUrl === pathToFileURL(
|
|
2663
|
+
return moduleUrl === pathToFileURL(realpathSync2(path5.resolve(entry))).href;
|
|
1197
2664
|
} catch {
|
|
1198
|
-
return moduleUrl === pathToFileURL(
|
|
2665
|
+
return moduleUrl === pathToFileURL(path5.resolve(entry)).href;
|
|
1199
2666
|
}
|
|
1200
2667
|
}
|
|
1201
2668
|
export {
|