@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/dist/cli/index.js CHANGED
@@ -5,53 +5,1231 @@ import {
5
5
  LINK_VERSION,
6
6
  LinkHttpError,
7
7
  clearPairingClaim,
8
- connectRelayControl,
8
+ createApp,
9
9
  createFileLogger,
10
- currentCliScriptPath,
11
- daemonLogFile,
10
+ createRotatingTextLogWriter,
12
11
  defaultLinkConfig,
13
12
  detectRuntimeEnvironment,
13
+ discoverRouteCandidates,
14
14
  ensureHermesApiServerAvailable,
15
15
  ensureHermesApiServerConfig,
16
16
  ensureIdentity,
17
- getDaemonStatus,
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
- startDaemonProcess,
37
- startLinkService,
38
- stopDaemonProcess
39
- } from "../chunk-PULX22HX.js";
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 path3 from "path";
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 path from "path";
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 mkdir(path.dirname(definition.filePath), { recursive: true, mode: 448 });
63
- await writeFile(definition.filePath, definition.content, { mode: 384 });
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", path.basename(definition.filePath)]).catch(async () => {
66
- await rm(definition.filePath, { force: true }).catch(() => void 0);
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 mkdir(path.dirname(fallback.filePath), { recursive: true, mode: 448 });
69
- await writeFile(fallback.filePath, fallback.content, { mode: 384 });
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", path.basename(definition.filePath)]).catch(() => void 0);
1256
+ await execFileAsync("systemctl", ["--user", "disable", path2.basename(definition.filePath)]).catch(() => void 0);
79
1257
  }
80
- await rm(definition.filePath, { force: true }).catch(() => void 0);
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 readFile(definition.filePath, "utf8").catch(() => null);
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 = path.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
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 = path.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
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 = path.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
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 ?? path.join(os.homedir(), "AppData", "Roaming");
201
- const filePath = path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
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: `@echo off\r
206
- start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon-supervisor\r
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 path2 from "path";
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 = path2.join(hermesHome, ".env");
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 = spawn(command, args, {
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(realpathSync(path3.resolve(entry))).href;
2663
+ return moduleUrl === pathToFileURL(realpathSync2(path5.resolve(entry))).href;
1197
2664
  } catch {
1198
- return moduleUrl === pathToFileURL(path3.resolve(entry)).href;
2665
+ return moduleUrl === pathToFileURL(path5.resolve(entry)).href;
1199
2666
  }
1200
2667
  }
1201
2668
  export {