@hermespilot/link 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -3,19 +3,530 @@ import {
3
3
  LINK_COMMAND,
4
4
  LINK_VERSION,
5
5
  createApp,
6
+ createFileLogger,
6
7
  ensureHermesApiServerKey,
7
8
  ensureIdentity,
8
9
  getIdentityStatus,
10
+ getLinkLogFile,
9
11
  loadConfig,
10
12
  loadIdentity,
11
13
  preparePairing,
12
14
  resolveRuntimePaths
13
- } from "../chunk-E2BRK5JT.js";
15
+ } from "../chunk-T35GPRKF.js";
14
16
 
15
17
  // src/cli/index.ts
16
18
  import { Command } from "commander";
17
19
  import qrcode from "qrcode-terminal";
18
20
 
21
+ // src/autostart/autostart.ts
22
+ import { execFile } from "child_process";
23
+ import { mkdir as mkdir3, readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
24
+ import os from "os";
25
+ import path2 from "path";
26
+ import { promisify } from "util";
27
+
28
+ // src/daemon/process.ts
29
+ import { spawn } from "child_process";
30
+ import { mkdir as mkdir2, open, readFile, rm as rm2 } from "fs/promises";
31
+ import path from "path";
32
+
33
+ // src/daemon/service.ts
34
+ import { createServer } from "http";
35
+ import { mkdir, rm, writeFile } from "fs/promises";
36
+
37
+ // src/relay/control-client.ts
38
+ import WebSocket from "ws";
39
+ function connectRelayControl(options) {
40
+ const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
41
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
42
+ wsUrl.searchParams.set("link_id", options.linkId);
43
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
44
+ const backoffBaseMs = options.backoffBaseMs ?? 1e3;
45
+ const backoffMaxMs = options.backoffMaxMs ?? 3e4;
46
+ let reconnectAttempts = 0;
47
+ let closedByUser = false;
48
+ let socket = null;
49
+ let retryTimer = null;
50
+ let abortControllers = /* @__PURE__ */ new Map();
51
+ const connect = () => {
52
+ options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
53
+ socket = new WebSocket(wsUrl, {
54
+ headers: {
55
+ "x-hermes-link-version": LINK_VERSION
56
+ }
57
+ });
58
+ socket.on("open", () => {
59
+ reconnectAttempts = 0;
60
+ options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
61
+ });
62
+ socket.on("message", (raw) => {
63
+ if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
64
+ return;
65
+ }
66
+ void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
67
+ const message = error instanceof Error ? error.message : "Relay request failed";
68
+ socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
69
+ });
70
+ });
71
+ socket.on("error", (error) => {
72
+ const message = error instanceof Error ? error.message : "Relay websocket error";
73
+ options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts, message });
74
+ });
75
+ socket.on("close", () => {
76
+ abortAll(abortControllers);
77
+ abortControllers = /* @__PURE__ */ new Map();
78
+ if (closedByUser) {
79
+ options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
80
+ return;
81
+ }
82
+ if (reconnectAttempts >= maxReconnectAttempts) {
83
+ options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
84
+ return;
85
+ }
86
+ reconnectAttempts += 1;
87
+ const delay = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
88
+ options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay}ms` });
89
+ retryTimer = setTimeout(connect, delay);
90
+ retryTimer.unref?.();
91
+ });
92
+ };
93
+ connect();
94
+ return {
95
+ close() {
96
+ closedByUser = true;
97
+ if (retryTimer) {
98
+ clearTimeout(retryTimer);
99
+ }
100
+ abortAll(abortControllers);
101
+ socket?.close();
102
+ }
103
+ };
104
+ }
105
+ function abortAll(abortControllers) {
106
+ for (const controller of abortControllers.values()) {
107
+ controller.abort();
108
+ }
109
+ abortControllers.clear();
110
+ }
111
+ function computeBackoffMs(attempt, baseMs, maxMs) {
112
+ const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
113
+ const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
114
+ return exponential + jitter;
115
+ }
116
+ async function handleFrame(socket, raw, localPort, abortControllers) {
117
+ const frame = JSON.parse(raw);
118
+ if (frame.type === "http.cancel") {
119
+ abortControllers.get(frame.id)?.abort();
120
+ abortControllers.delete(frame.id);
121
+ return;
122
+ }
123
+ if (frame.type !== "http.request") {
124
+ return;
125
+ }
126
+ const abortController = new AbortController();
127
+ abortControllers.set(frame.id, abortController);
128
+ try {
129
+ const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
130
+ method: frame.method,
131
+ headers: frame.headers ?? {},
132
+ body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
133
+ signal: abortController.signal
134
+ });
135
+ const headers = Object.fromEntries(response.headers.entries());
136
+ const contentType = response.headers.get("content-type") ?? "";
137
+ if (response.body && contentType.includes("text/event-stream")) {
138
+ socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
139
+ const reader = response.body.getReader();
140
+ while (true) {
141
+ const next = await reader.read();
142
+ if (next.done) {
143
+ break;
144
+ }
145
+ socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
146
+ }
147
+ socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
148
+ return;
149
+ }
150
+ const body = Buffer.from(await response.arrayBuffer()).toString("base64");
151
+ socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
152
+ } catch (error) {
153
+ const message = error instanceof Error ? error.message : "Relay request failed";
154
+ socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
155
+ } finally {
156
+ abortControllers.delete(frame.id);
157
+ }
158
+ }
159
+
160
+ // src/daemon/service.ts
161
+ async function startLinkService(options = {}) {
162
+ const paths = options.paths ?? resolveRuntimePaths();
163
+ const logger = createFileLogger({ paths });
164
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
165
+ await logger.info("service_starting", {
166
+ port: config.port,
167
+ mode: identity?.link_id ? "paired" : "local-only"
168
+ });
169
+ const app = await createApp({ paths, logger, onPairingClaimed: options.onPairingClaimed });
170
+ const server = createServer(app.callback());
171
+ try {
172
+ await listenServer(server, config.port);
173
+ } catch (error) {
174
+ await logger.error("service_start_failed", {
175
+ port: config.port,
176
+ error: error instanceof Error ? error.message : String(error)
177
+ });
178
+ await logger.flush();
179
+ throw error;
180
+ }
181
+ server.on("error", (error) => {
182
+ void logger.error("service_error", { error: error.message });
183
+ });
184
+ void logger.info("service_started", {
185
+ port: config.port,
186
+ link_id: identity?.link_id ?? null
187
+ });
188
+ let relay = null;
189
+ if (identity?.link_id) {
190
+ relay = connectRelayControl({
191
+ relayBaseUrl: config.relayBaseUrl,
192
+ linkId: identity.link_id,
193
+ localPort: config.port,
194
+ maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
195
+ backoffBaseMs: 1e3,
196
+ backoffMaxMs: 3e4,
197
+ onStatus: (status) => {
198
+ void logger.info("relay_status", status);
199
+ }
200
+ });
201
+ } else {
202
+ void logger.info("relay_skipped", { reason: "link_not_paired" });
203
+ }
204
+ if (options.writePidFile) {
205
+ await writePidFile(paths);
206
+ }
207
+ return {
208
+ async close() {
209
+ relay?.close();
210
+ await closeServer(server);
211
+ await logger.info("service_stopped");
212
+ await logger.flush();
213
+ if (options.writePidFile) {
214
+ await rm(pidFilePath(paths), { force: true }).catch(() => void 0);
215
+ }
216
+ }
217
+ };
218
+ }
219
+ function pidFilePath(paths = resolveRuntimePaths()) {
220
+ return `${paths.runDir}/hermeslink.pid`;
221
+ }
222
+ async function writePidFile(paths) {
223
+ await mkdir(paths.runDir, { recursive: true, mode: 448 });
224
+ await writeFile(pidFilePath(paths), `${process.pid}
225
+ `, { mode: 384 });
226
+ }
227
+ async function closeServer(server) {
228
+ await new Promise((resolve, reject) => {
229
+ server.close((error) => {
230
+ if (error) {
231
+ reject(error);
232
+ return;
233
+ }
234
+ resolve();
235
+ });
236
+ });
237
+ }
238
+ async function listenServer(server, port) {
239
+ await new Promise((resolve, reject) => {
240
+ const cleanup = () => {
241
+ server.off("error", onError);
242
+ server.off("listening", onListening);
243
+ };
244
+ const onError = (error) => {
245
+ cleanup();
246
+ reject(error);
247
+ };
248
+ const onListening = () => {
249
+ cleanup();
250
+ resolve();
251
+ };
252
+ server.once("error", onError);
253
+ server.once("listening", onListening);
254
+ server.listen(port);
255
+ });
256
+ }
257
+
258
+ // src/daemon/process.ts
259
+ async function startDaemonProcess(paths = resolveRuntimePaths()) {
260
+ const status = await getDaemonStatus(paths);
261
+ if (status.running) {
262
+ return status;
263
+ }
264
+ await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
265
+ await mkdir2(paths.runDir, { recursive: true, mode: 448 });
266
+ const log = await open(daemonLogFile(paths), "a", 384);
267
+ const scriptPath = currentCliScriptPath();
268
+ const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
269
+ detached: true,
270
+ stdio: ["ignore", log.fd, log.fd],
271
+ env: process.env
272
+ });
273
+ child.unref();
274
+ await log.close();
275
+ for (let index = 0; index < 12; index += 1) {
276
+ await wait(250);
277
+ const next = await getDaemonStatus(paths);
278
+ if (next.running) {
279
+ return next;
280
+ }
281
+ }
282
+ return await getDaemonStatus(paths);
283
+ }
284
+ async function stopDaemonProcess(paths = resolveRuntimePaths()) {
285
+ const status = await getDaemonStatus(paths);
286
+ if (!status.running || !status.pid) {
287
+ return status;
288
+ }
289
+ try {
290
+ process.kill(status.pid, "SIGTERM");
291
+ } catch {
292
+ await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
293
+ return await getDaemonStatus(paths);
294
+ }
295
+ for (let index = 0; index < 20; index += 1) {
296
+ await wait(250);
297
+ if (!isProcessAlive(status.pid)) {
298
+ break;
299
+ }
300
+ }
301
+ if (!isProcessAlive(status.pid)) {
302
+ await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
303
+ }
304
+ return await getDaemonStatus(paths);
305
+ }
306
+ async function getDaemonStatus(paths = resolveRuntimePaths()) {
307
+ const pidFile = pidFilePath(paths);
308
+ const pid = await readPid(pidFile);
309
+ if (pid && !isProcessAlive(pid)) {
310
+ await rm2(pidFile, { force: true }).catch(() => void 0);
311
+ return {
312
+ running: false,
313
+ pid: null,
314
+ pidFile,
315
+ logFile: daemonLogFile(paths)
316
+ };
317
+ }
318
+ return {
319
+ running: Boolean(pid),
320
+ pid,
321
+ pidFile,
322
+ logFile: daemonLogFile(paths)
323
+ };
324
+ }
325
+ function daemonLogFile(paths = resolveRuntimePaths()) {
326
+ return path.join(paths.logsDir, "daemon.log");
327
+ }
328
+ function currentCliScriptPath() {
329
+ return process.argv[1];
330
+ }
331
+ async function readPid(filePath) {
332
+ const raw = await readFile(filePath, "utf8").catch(() => null);
333
+ if (!raw) {
334
+ return null;
335
+ }
336
+ const pid = Number.parseInt(raw.trim(), 10);
337
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
338
+ }
339
+ function isProcessAlive(pid) {
340
+ try {
341
+ process.kill(pid, 0);
342
+ return true;
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+ function wait(ms) {
348
+ return new Promise((resolve) => setTimeout(resolve, ms));
349
+ }
350
+
351
+ // src/autostart/autostart.ts
352
+ var execFileAsync = promisify(execFile);
353
+ var MACOS_LABEL = "com.hermespilot.link";
354
+ async function enableAutostart() {
355
+ const definition = await resolveAutostartDefinition();
356
+ if (!definition) {
357
+ return unsupportedStatus();
358
+ }
359
+ await mkdir3(path2.dirname(definition.filePath), { recursive: true, mode: 448 });
360
+ await writeFile2(definition.filePath, definition.content, { mode: 384 });
361
+ if (definition.method === "systemd-user") {
362
+ await execFileAsync("systemctl", ["--user", "enable", path2.basename(definition.filePath)]).catch(async () => {
363
+ await rm3(definition.filePath, { force: true }).catch(() => void 0);
364
+ const fallback = xdgAutostartDefinition();
365
+ await mkdir3(path2.dirname(fallback.filePath), { recursive: true, mode: 448 });
366
+ await writeFile2(fallback.filePath, fallback.content, { mode: 384 });
367
+ });
368
+ }
369
+ return await getAutostartStatus();
370
+ }
371
+ async function disableAutostart() {
372
+ const definitions = await allAutostartDefinitions();
373
+ for (const definition of definitions) {
374
+ if (definition.method === "systemd-user") {
375
+ await execFileAsync("systemctl", ["--user", "disable", path2.basename(definition.filePath)]).catch(() => void 0);
376
+ }
377
+ await rm3(definition.filePath, { force: true }).catch(() => void 0);
378
+ }
379
+ return await getAutostartStatus();
380
+ }
381
+ async function getAutostartStatus() {
382
+ const definitions = await allAutostartDefinitions();
383
+ if (definitions.length === 0) {
384
+ return unsupportedStatus();
385
+ }
386
+ for (const definition of definitions) {
387
+ const content = await readFile2(definition.filePath, "utf8").catch(() => null);
388
+ if (content !== null) {
389
+ return {
390
+ supported: true,
391
+ enabled: true,
392
+ method: definition.method,
393
+ filePath: definition.filePath
394
+ };
395
+ }
396
+ }
397
+ const primary = definitions[0];
398
+ return {
399
+ supported: true,
400
+ enabled: false,
401
+ method: primary.method,
402
+ filePath: primary.filePath
403
+ };
404
+ }
405
+ async function resolveAutostartDefinition() {
406
+ if (process.platform === "darwin") {
407
+ return launchdDefinition();
408
+ }
409
+ if (process.platform === "win32") {
410
+ return windowsStartupDefinition();
411
+ }
412
+ if (process.platform === "linux") {
413
+ return await hasSystemctlUser() ? systemdUserDefinition() : xdgAutostartDefinition();
414
+ }
415
+ return null;
416
+ }
417
+ async function allAutostartDefinitions() {
418
+ if (process.platform === "darwin") {
419
+ return [launchdDefinition()];
420
+ }
421
+ if (process.platform === "win32") {
422
+ return [windowsStartupDefinition()];
423
+ }
424
+ if (process.platform === "linux") {
425
+ return [systemdUserDefinition(), xdgAutostartDefinition()];
426
+ }
427
+ return [];
428
+ }
429
+ async function hasSystemctlUser() {
430
+ try {
431
+ await execFileAsync("systemctl", ["--user", "show-environment"]);
432
+ return true;
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
437
+ function launchdDefinition() {
438
+ const filePath = path2.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
439
+ return {
440
+ method: "launchd",
441
+ filePath,
442
+ content: `<?xml version="1.0" encoding="UTF-8"?>
443
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
444
+ <plist version="1.0">
445
+ <dict>
446
+ <key>Label</key>
447
+ <string>${MACOS_LABEL}</string>
448
+ <key>ProgramArguments</key>
449
+ <array>
450
+ <string>${xmlEscape(process.execPath)}</string>
451
+ <string>${xmlEscape(currentCliScriptPath())}</string>
452
+ <string>daemon</string>
453
+ <string>--foreground</string>
454
+ </array>
455
+ <key>RunAtLoad</key>
456
+ <true/>
457
+ <key>KeepAlive</key>
458
+ <false/>
459
+ <key>StandardOutPath</key>
460
+ <string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
461
+ <key>StandardErrorPath</key>
462
+ <string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
463
+ </dict>
464
+ </plist>
465
+ `
466
+ };
467
+ }
468
+ function systemdUserDefinition() {
469
+ const filePath = path2.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
470
+ return {
471
+ method: "systemd-user",
472
+ filePath,
473
+ content: `[Unit]
474
+ Description=Hermes Link
475
+ After=network-online.target
476
+
477
+ [Service]
478
+ Type=simple
479
+ ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon --foreground
480
+ Restart=no
481
+
482
+ [Install]
483
+ WantedBy=default.target
484
+ `
485
+ };
486
+ }
487
+ function xdgAutostartDefinition() {
488
+ const filePath = path2.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
489
+ return {
490
+ method: "xdg-autostart",
491
+ filePath,
492
+ content: `[Desktop Entry]
493
+ Type=Application
494
+ Name=Hermes Link
495
+ Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon --foreground
496
+ Terminal=false
497
+ X-GNOME-Autostart-enabled=true
498
+ `
499
+ };
500
+ }
501
+ function windowsStartupDefinition() {
502
+ const appData = process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming");
503
+ const filePath = path2.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
504
+ return {
505
+ method: "windows-startup",
506
+ filePath,
507
+ content: `@echo off\r
508
+ start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon --foreground\r
509
+ `
510
+ };
511
+ }
512
+ function unsupportedStatus() {
513
+ return {
514
+ supported: false,
515
+ enabled: false,
516
+ method: "unsupported",
517
+ filePath: null
518
+ };
519
+ }
520
+ function xmlEscape(value) {
521
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
522
+ }
523
+ function systemdQuote(value) {
524
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
525
+ }
526
+ function desktopQuote(value) {
527
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
528
+ }
529
+
19
530
  // src/i18n.ts
20
531
  var messages = {
21
532
  en: {
@@ -29,10 +540,30 @@ var messages = {
29
540
  "status.linkId": "Link ID: {value}",
30
541
  "status.notPaired": "not paired",
31
542
  "start.description": "Start Hermes Link daemon",
543
+ "start.backgroundStarted": "Hermes Link is running in the background. PID: {pid}",
544
+ "start.alreadyRunning": "Hermes Link is already running. PID: {pid}",
32
545
  "start.notPaired": "Hermes Link is not paired yet. Starting in local-only maintenance mode.",
33
546
  "start.notPaired.detail": "Relay, Server polling, and LAN entrypoints stay disabled until you run `hermeslink pair`.",
34
547
  "start.listening": "Hermes Link API listening on http://127.0.0.1:{port}",
35
548
  "start.relayConnecting": "Relay control connecting for {linkId}",
549
+ "stop.description": "Stop the background Hermes Link daemon",
550
+ "stop.stopped": "Hermes Link stopped.",
551
+ "stop.notRunning": "Hermes Link is not running.",
552
+ "restart.description": "Restart the background Hermes Link daemon",
553
+ "daemon.description": "Run Hermes Link in the foreground",
554
+ "daemon.foreground": "Hermes Link foreground daemon is running. Press Ctrl+C to stop.",
555
+ "logs.description": "Show Hermes Link log paths",
556
+ "logs.servicePath": "Service log: {path}",
557
+ "logs.daemonPath": "Daemon stdout/stderr log: {path}",
558
+ "autostart.description": "Manage boot autostart",
559
+ "autostart.on.description": "Enable boot autostart",
560
+ "autostart.off.description": "Disable boot autostart",
561
+ "autostart.status.description": "Show boot autostart status",
562
+ "autostart.enabled": "Boot autostart enabled via {method}: {path}",
563
+ "autostart.disabled": "Boot autostart disabled.",
564
+ "autostart.status.enabled": "Boot autostart: enabled via {method}: {path}",
565
+ "autostart.status.disabled": "Boot autostart: disabled. Method: {method}. File: {path}",
566
+ "autostart.unsupported": "Boot autostart is not supported on this platform yet.",
36
567
  "pair.description": "Create a Hermes Link pairing session",
37
568
  "pair.preparing": "Preparing pairing session through HermesPilot Server and Relay...",
38
569
  "pair.server": "Server: {url}",
@@ -42,6 +573,8 @@ var messages = {
42
573
  "pair.localApi": "Local API: http://127.0.0.1:{port}",
43
574
  "pair.scan": "Scan this QR code with the HermesPilot App:",
44
575
  "pair.expires": "Pairing expires in 10 minutes. Press Ctrl+C to stop Hermes Link.",
576
+ "pair.claimed": "Pairing succeeded. Starting Hermes Link in the background...",
577
+ "pair.autostartFailed": "Pairing succeeded, but boot autostart could not be enabled: {message}",
45
578
  "doctor.description": "Run local diagnostics",
46
579
  "doctor.identityOk": "Runtime identity: OK",
47
580
  "doctor.installId": "Install ID: {value}",
@@ -52,6 +585,7 @@ var messages = {
52
585
  "error.relayLinkInvalid": "Relay did not return a valid link_id.",
53
586
  "error.relayEmpty": "Relay returned an empty response.",
54
587
  "error.serverHttp": "HermesPilot Server request failed with HTTP {status}.",
588
+ "error.portInUse": "Local port {port} is already in use. Stop the existing Hermes Link process, then run `hermeslink pair` again.",
55
589
  "error.pairingRequires": "Pairing needs HermesPilot Server and Relay, but this command could not start a complete pairing session.",
56
590
  "error.pairingRequires.detail": "The deployed services may be healthy, but the installed Link package must call Server for a short-lived relay bootstrap token before it can request a link_id."
57
591
  },
@@ -66,10 +600,30 @@ var messages = {
66
600
  "status.linkId": "Link ID\uFF1A{value}",
67
601
  "status.notPaired": "\u5C1A\u672A\u914D\u5BF9",
68
602
  "start.description": "\u542F\u52A8 Hermes Link \u670D\u52A1",
603
+ "start.backgroundStarted": "Hermes Link \u5DF2\u5728\u540E\u53F0\u8FD0\u884C\u3002PID\uFF1A{pid}",
604
+ "start.alreadyRunning": "Hermes Link \u5DF2\u7ECF\u5728\u8FD0\u884C\u3002PID\uFF1A{pid}",
69
605
  "start.notPaired": "Hermes Link \u8FD8\u6CA1\u6709\u914D\u5BF9\uFF0C\u5C06\u4EE5\u672C\u5730\u7EF4\u62A4\u6A21\u5F0F\u542F\u52A8\u3002",
70
606
  "start.notPaired.detail": "\u5728\u4F60\u8FD0\u884C `hermeslink pair` \u524D\uFF0CRelay\u3001Server \u8F6E\u8BE2\u548C\u5C40\u57DF\u7F51\u5165\u53E3\u90FD\u4F1A\u4FDD\u6301\u5173\u95ED\u3002",
71
607
  "start.listening": "Hermes Link API \u6B63\u5728\u76D1\u542C http://127.0.0.1:{port}",
72
608
  "start.relayConnecting": "\u6B63\u5728\u4E3A {linkId} \u8FDE\u63A5 Relay \u63A7\u5236\u901A\u9053",
609
+ "stop.description": "\u505C\u6B62\u540E\u53F0 Hermes Link \u670D\u52A1",
610
+ "stop.stopped": "Hermes Link \u5DF2\u505C\u6B62\u3002",
611
+ "stop.notRunning": "Hermes Link \u6CA1\u6709\u5728\u8FD0\u884C\u3002",
612
+ "restart.description": "\u91CD\u542F\u540E\u53F0 Hermes Link \u670D\u52A1",
613
+ "daemon.description": "\u4EE5\u524D\u53F0\u65B9\u5F0F\u8FD0\u884C Hermes Link",
614
+ "daemon.foreground": "Hermes Link \u524D\u53F0\u670D\u52A1\u6B63\u5728\u8FD0\u884C\u3002\u6309 Ctrl+C \u505C\u6B62\u3002",
615
+ "logs.description": "\u663E\u793A Hermes Link \u65E5\u5FD7\u8DEF\u5F84",
616
+ "logs.servicePath": "\u670D\u52A1\u65E5\u5FD7\uFF1A{path}",
617
+ "logs.daemonPath": "Daemon \u6807\u51C6\u8F93\u51FA/\u9519\u8BEF\u65E5\u5FD7\uFF1A{path}",
618
+ "autostart.description": "\u7BA1\u7406\u5F00\u673A\u81EA\u542F",
619
+ "autostart.on.description": "\u542F\u7528\u5F00\u673A\u81EA\u542F",
620
+ "autostart.off.description": "\u5173\u95ED\u5F00\u673A\u81EA\u542F",
621
+ "autostart.status.description": "\u67E5\u770B\u5F00\u673A\u81EA\u542F\u72B6\u6001",
622
+ "autostart.enabled": "\u5DF2\u542F\u7528\u5F00\u673A\u81EA\u542F\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
623
+ "autostart.disabled": "\u5DF2\u5173\u95ED\u5F00\u673A\u81EA\u542F\u3002",
624
+ "autostart.status.enabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
625
+ "autostart.status.disabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u672A\u542F\u7528\u3002\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
626
+ "autostart.unsupported": "\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u5F00\u673A\u81EA\u542F\u3002",
73
627
  "pair.description": "\u521B\u5EFA Hermes Link \u914D\u5BF9\u4F1A\u8BDD",
74
628
  "pair.preparing": "\u6B63\u5728\u901A\u8FC7 HermesPilot Server \u548C Relay \u521B\u5EFA\u914D\u5BF9\u4F1A\u8BDD...",
75
629
  "pair.server": "Server\uFF1A{url}",
@@ -79,6 +633,8 @@ var messages = {
79
633
  "pair.localApi": "\u672C\u5730 API\uFF1Ahttp://127.0.0.1:{port}",
80
634
  "pair.scan": "\u8BF7\u4F7F\u7528 HermesPilot App \u626B\u63CF\u8FD9\u4E2A\u4E8C\u7EF4\u7801\uFF1A",
81
635
  "pair.expires": "\u914D\u5BF9\u4F1A\u8BDD 10 \u5206\u949F\u540E\u8FC7\u671F\u3002\u6309 Ctrl+C \u505C\u6B62 Hermes Link\u3002",
636
+ "pair.claimed": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002\u6B63\u5728\u628A Hermes Link \u5207\u6362\u5230\u540E\u53F0\u8FD0\u884C...",
637
+ "pair.autostartFailed": "\u914D\u5BF9\u5DF2\u6210\u529F\uFF0C\u4F46\u542F\u7528\u5F00\u673A\u81EA\u542F\u5931\u8D25\uFF1A{message}",
82
638
  "doctor.description": "\u8FD0\u884C\u672C\u673A\u8BCA\u65AD",
83
639
  "doctor.identityOk": "\u8FD0\u884C\u8EAB\u4EFD\uFF1A\u6B63\u5E38",
84
640
  "doctor.installId": "Install ID\uFF1A{value}",
@@ -89,6 +645,7 @@ var messages = {
89
645
  "error.relayLinkInvalid": "Relay \u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684 link_id\u3002",
90
646
  "error.relayEmpty": "Relay \u8FD4\u56DE\u4E86\u7A7A\u54CD\u5E94\u3002",
91
647
  "error.serverHttp": "HermesPilot Server \u8BF7\u6C42\u5931\u8D25\uFF0CHTTP \u72B6\u6001\u7801\uFF1A{status}\u3002",
648
+ "error.portInUse": "\u672C\u5730\u7AEF\u53E3 {port} \u5DF2\u88AB\u5360\u7528\u3002\u8BF7\u5148\u505C\u6B62\u5DF2\u6709\u7684 Hermes Link \u8FDB\u7A0B\uFF0C\u7136\u540E\u91CD\u65B0\u8FD0\u884C `hermeslink pair`\u3002",
92
649
  "error.pairingRequires": "\u914D\u5BF9\u9700\u8981 HermesPilot Server \u548C Relay\uFF0C\u4F46\u5F53\u524D\u547D\u4EE4\u6CA1\u6709\u80FD\u542F\u52A8\u5B8C\u6574\u914D\u5BF9\u4F1A\u8BDD\u3002",
93
650
  "error.pairingRequires.detail": "\u4E91\u7AEF\u670D\u52A1\u53EF\u4EE5\u662F\u5DF2\u90E8\u7F72\u4E14\u5065\u5EB7\u7684\uFF1B\u672C\u673A Link \u4ECD\u5FC5\u987B\u5148\u5411 Server \u7533\u8BF7\u77ED\u671F relay bootstrap token\uFF0C\u624D\u80FD\u518D\u5411 Relay \u7533\u8BF7 link_id\u3002"
94
651
  }
@@ -143,6 +700,10 @@ function translateKnownError(message, language) {
143
700
  if (message === "Relay returned an empty response") {
144
701
  return translate(language, "error.relayEmpty");
145
702
  }
703
+ const portInUse = /^listen EADDRINUSE: address already in use .*:(?<port>\d+)$/u.exec(message);
704
+ if (portInUse?.groups?.port) {
705
+ return translate(language, "error.portInUse", { port: portInUse.groups.port });
706
+ }
146
707
  const serverHttp = /^HermesPilot Server request failed with HTTP (?<status>\d+)$/u.exec(message);
147
708
  if (serverHttp?.groups?.status) {
148
709
  return translate(language, "error.serverHttp", { status: serverHttp.groups.status });
@@ -166,83 +727,6 @@ function parseLanguage(value) {
166
727
  return null;
167
728
  }
168
729
 
169
- // src/relay/control-client.ts
170
- import WebSocket from "ws";
171
- function connectRelayControl(options) {
172
- const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
173
- wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
174
- wsUrl.searchParams.set("link_id", options.linkId);
175
- const socket = new WebSocket(wsUrl, {
176
- headers: {
177
- "x-hermes-link-version": LINK_VERSION
178
- }
179
- });
180
- const abortControllers = /* @__PURE__ */ new Map();
181
- socket.on("message", (raw) => {
182
- if (typeof raw !== "string" && !Buffer.isBuffer(raw)) {
183
- return;
184
- }
185
- void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
186
- const message = error instanceof Error ? error.message : "Relay request failed";
187
- socket.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
188
- });
189
- });
190
- socket.on("close", () => {
191
- for (const controller of abortControllers.values()) {
192
- controller.abort();
193
- }
194
- abortControllers.clear();
195
- });
196
- return {
197
- close() {
198
- socket.close();
199
- }
200
- };
201
- }
202
- async function handleFrame(socket, raw, localPort, abortControllers) {
203
- const frame = JSON.parse(raw);
204
- if (frame.type === "http.cancel") {
205
- abortControllers.get(frame.id)?.abort();
206
- abortControllers.delete(frame.id);
207
- return;
208
- }
209
- if (frame.type !== "http.request") {
210
- return;
211
- }
212
- const abortController = new AbortController();
213
- abortControllers.set(frame.id, abortController);
214
- try {
215
- const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
216
- method: frame.method,
217
- headers: frame.headers ?? {},
218
- body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
219
- signal: abortController.signal
220
- });
221
- const headers = Object.fromEntries(response.headers.entries());
222
- const contentType = response.headers.get("content-type") ?? "";
223
- if (response.body && contentType.includes("text/event-stream")) {
224
- socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
225
- const reader = response.body.getReader();
226
- while (true) {
227
- const next = await reader.read();
228
- if (next.done) {
229
- break;
230
- }
231
- socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
232
- }
233
- socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
234
- return;
235
- }
236
- const body = Buffer.from(await response.arrayBuffer()).toString("base64");
237
- socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
238
- } catch (error) {
239
- const message = error instanceof Error ? error.message : "Relay request failed";
240
- socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
241
- } finally {
242
- abortControllers.delete(frame.id);
243
- }
244
- }
245
-
246
730
  // src/cli/index.ts
247
731
  var program = new Command();
248
732
  var helpLanguage = detectSystemLanguage();
@@ -276,26 +760,44 @@ program.command("status").option("--json", helpText("status.json")).description(
276
760
  console.log(t("status.linkId", { value: payload.identity?.linkId ?? t("status.notPaired") }));
277
761
  });
278
762
  program.command("start").description(helpText("start.description")).action(async () => {
279
- const [identity, config] = await Promise.all([loadIdentity(), loadConfig()]);
763
+ const [config, status] = await Promise.all([loadConfig(), getDaemonStatus()]);
280
764
  const language = resolveLanguage(config.language);
281
765
  const t = translate.bind(null, language);
282
- if (!identity?.link_id) {
283
- console.log(t("start.notPaired"));
284
- console.log(t("start.notPaired.detail"));
285
- }
286
- const server = await startHttpServer(config.port);
287
- const relay = identity?.link_id ? connectRelayControl({
288
- relayBaseUrl: config.relayBaseUrl,
289
- linkId: identity.link_id,
290
- localPort: config.port
291
- }) : null;
292
- console.log(t("start.listening", { port: config.port }));
293
- if (identity?.link_id) {
294
- console.log(t("start.relayConnecting", { linkId: identity.link_id }));
766
+ if (status.running && status.pid) {
767
+ console.log(t("start.alreadyRunning", { pid: status.pid }));
768
+ return;
769
+ }
770
+ const nextStatus = await startDaemonProcess();
771
+ console.log(t("start.backgroundStarted", { pid: nextStatus.pid ?? "unknown" }));
772
+ });
773
+ program.command("stop").description(helpText("stop.description")).action(async () => {
774
+ const config = await loadConfig();
775
+ const language = resolveLanguage(config.language);
776
+ const t = translate.bind(null, language);
777
+ const before = await getDaemonStatus();
778
+ if (!before.running) {
779
+ console.log(t("stop.notRunning"));
780
+ return;
295
781
  }
782
+ await stopDaemonProcess();
783
+ console.log(t("stop.stopped"));
784
+ });
785
+ program.command("restart").description(helpText("restart.description")).action(async () => {
786
+ const config = await loadConfig();
787
+ const language = resolveLanguage(config.language);
788
+ const t = translate.bind(null, language);
789
+ await stopDaemonProcess();
790
+ const status = await startDaemonProcess();
791
+ console.log(t("start.backgroundStarted", { pid: status.pid ?? "unknown" }));
792
+ });
793
+ program.command("daemon").option("--foreground", "run in foreground").description(helpText("daemon.description")).action(async () => {
794
+ const config = await loadConfig();
795
+ const language = resolveLanguage(config.language);
796
+ const t = translate.bind(null, language);
797
+ const service = await startLinkService({ writePidFile: true });
798
+ console.log(t("daemon.foreground"));
296
799
  await waitForShutdown(async () => {
297
- relay?.close();
298
- await new Promise((resolve) => server.close(() => resolve()));
800
+ await service.close();
299
801
  });
300
802
  });
301
803
  program.command("pair").description(helpText("pair.description")).action(async () => {
@@ -308,11 +810,10 @@ program.command("pair").description(helpText("pair.description")).action(async (
308
810
  console.log(t("pair.relay", { url: config.relayBaseUrl }));
309
811
  await ensureIdentity(paths);
310
812
  const prepared = await preparePairing(paths);
311
- const server = await startHttpServer(config.port);
312
- const relay = connectRelayControl({
313
- relayBaseUrl: prepared.relayBaseUrl,
314
- linkId: prepared.linkId,
315
- localPort: config.port
813
+ const pairingClaimed = createDeferred();
814
+ const service = await startLinkService({
815
+ paths,
816
+ onPairingClaimed: () => pairingClaimed.resolve()
316
817
  });
317
818
  const qrValue = JSON.stringify(prepared.qrPayload);
318
819
  console.log(t("pair.linkId", { value: prepared.linkId }));
@@ -321,10 +822,65 @@ program.command("pair").description(helpText("pair.description")).action(async (
321
822
  console.log(t("pair.scan"));
322
823
  qrcode.generate(qrValue, { small: true });
323
824
  console.log(t("pair.expires"));
324
- await waitForShutdown(async () => {
325
- relay.close();
326
- await new Promise((resolve) => server.close(() => resolve()));
327
- });
825
+ const result = await waitForPairingOrShutdown(pairingClaimed.promise);
826
+ await service.close();
827
+ if (result === "claimed") {
828
+ console.log(t("pair.claimed"));
829
+ try {
830
+ const autostart2 = await enableAutostart();
831
+ if (autostart2.supported && autostart2.enabled) {
832
+ console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
833
+ }
834
+ } catch (error) {
835
+ const message = error instanceof Error ? error.message : String(error);
836
+ console.log(t("pair.autostartFailed", { message }));
837
+ }
838
+ const status = await startDaemonProcess(paths);
839
+ console.log(t("start.backgroundStarted", { pid: status.pid ?? "unknown" }));
840
+ }
841
+ });
842
+ var autostart = program.command("autostart").description(helpText("autostart.description"));
843
+ autostart.command("on").description(helpText("autostart.on.description")).action(async () => {
844
+ const config = await loadConfig();
845
+ const language = resolveLanguage(config.language);
846
+ const t = translate.bind(null, language);
847
+ const status = await enableAutostart();
848
+ if (!status.supported) {
849
+ console.log(t("autostart.unsupported"));
850
+ return;
851
+ }
852
+ console.log(t("autostart.enabled", { method: status.method, path: status.filePath ?? "" }));
853
+ });
854
+ autostart.command("off").description(helpText("autostart.off.description")).action(async () => {
855
+ const config = await loadConfig();
856
+ const language = resolveLanguage(config.language);
857
+ const t = translate.bind(null, language);
858
+ await disableAutostart();
859
+ console.log(t("autostart.disabled"));
860
+ });
861
+ autostart.command("status").description(helpText("autostart.status.description")).action(async () => {
862
+ const config = await loadConfig();
863
+ const language = resolveLanguage(config.language);
864
+ const t = translate.bind(null, language);
865
+ const status = await getAutostartStatus();
866
+ if (!status.supported) {
867
+ console.log(t("autostart.unsupported"));
868
+ return;
869
+ }
870
+ console.log(
871
+ t(status.enabled ? "autostart.status.enabled" : "autostart.status.disabled", {
872
+ method: status.method,
873
+ path: status.filePath ?? ""
874
+ })
875
+ );
876
+ });
877
+ program.command("logs").description(helpText("logs.description")).action(async () => {
878
+ const paths = resolveRuntimePaths();
879
+ const config = await loadConfig(paths);
880
+ const language = resolveLanguage(config.language);
881
+ const t = translate.bind(null, language);
882
+ console.log(t("logs.servicePath", { path: getLinkLogFile(paths) }));
883
+ console.log(t("logs.daemonPath", { path: daemonLogFile(paths) }));
328
884
  });
329
885
  program.command("doctor").description(helpText("doctor.description")).action(async () => {
330
886
  const [identity, config] = await Promise.all([ensureIdentity(), loadConfig()]);
@@ -350,10 +906,6 @@ async function loadCliLanguage() {
350
906
  const config = await loadConfig();
351
907
  return resolveLanguage(config.language);
352
908
  }
353
- async function startHttpServer(port) {
354
- const app = await createApp();
355
- return app.listen(port);
356
- }
357
909
  async function waitForShutdown(cleanup) {
358
910
  await new Promise((resolve) => {
359
911
  const stop = () => resolve();
@@ -362,4 +914,25 @@ async function waitForShutdown(cleanup) {
362
914
  });
363
915
  await cleanup();
364
916
  }
917
+ async function waitForPairingOrShutdown(pairingClaimed) {
918
+ let stop = null;
919
+ const shutdown = new Promise((resolve) => {
920
+ stop = () => resolve("shutdown");
921
+ process.once("SIGINT", stop);
922
+ process.once("SIGTERM", stop);
923
+ });
924
+ const result = await Promise.race([pairingClaimed.then(() => "claimed"), shutdown]);
925
+ if (stop) {
926
+ process.off("SIGINT", stop);
927
+ process.off("SIGTERM", stop);
928
+ }
929
+ return result;
930
+ }
931
+ function createDeferred() {
932
+ let resolve;
933
+ const promise = new Promise((innerResolve) => {
934
+ resolve = innerResolve;
935
+ });
936
+ return { promise, resolve };
937
+ }
365
938
  //# sourceMappingURL=index.js.map