@hermespilot/link 0.1.1 → 0.1.3

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,500 @@ 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-4CDHEW3J.js";
15
+ } from "../chunk-7M3UZCA7.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 { mkdir, rm, writeFile } from "fs/promises";
35
+
36
+ // src/relay/control-client.ts
37
+ import WebSocket from "ws";
38
+ function connectRelayControl(options) {
39
+ const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
40
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
41
+ wsUrl.searchParams.set("link_id", options.linkId);
42
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
43
+ const backoffBaseMs = options.backoffBaseMs ?? 1e3;
44
+ const backoffMaxMs = options.backoffMaxMs ?? 3e4;
45
+ let reconnectAttempts = 0;
46
+ let closedByUser = false;
47
+ let socket = null;
48
+ let retryTimer = null;
49
+ let abortControllers = /* @__PURE__ */ new Map();
50
+ const connect = () => {
51
+ options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
52
+ socket = new WebSocket(wsUrl, {
53
+ headers: {
54
+ "x-hermes-link-version": LINK_VERSION
55
+ }
56
+ });
57
+ socket.on("open", () => {
58
+ reconnectAttempts = 0;
59
+ options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
60
+ });
61
+ socket.on("message", (raw) => {
62
+ if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
63
+ return;
64
+ }
65
+ void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
66
+ const message = error instanceof Error ? error.message : "Relay request failed";
67
+ socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
68
+ });
69
+ });
70
+ socket.on("error", (error) => {
71
+ const message = error instanceof Error ? error.message : "Relay websocket error";
72
+ options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts, message });
73
+ });
74
+ socket.on("close", () => {
75
+ abortAll(abortControllers);
76
+ abortControllers = /* @__PURE__ */ new Map();
77
+ if (closedByUser) {
78
+ options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
79
+ return;
80
+ }
81
+ if (reconnectAttempts >= maxReconnectAttempts) {
82
+ options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
83
+ return;
84
+ }
85
+ reconnectAttempts += 1;
86
+ const delay = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
87
+ options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay}ms` });
88
+ retryTimer = setTimeout(connect, delay);
89
+ retryTimer.unref?.();
90
+ });
91
+ };
92
+ connect();
93
+ return {
94
+ close() {
95
+ closedByUser = true;
96
+ if (retryTimer) {
97
+ clearTimeout(retryTimer);
98
+ }
99
+ abortAll(abortControllers);
100
+ socket?.close();
101
+ }
102
+ };
103
+ }
104
+ function abortAll(abortControllers) {
105
+ for (const controller of abortControllers.values()) {
106
+ controller.abort();
107
+ }
108
+ abortControllers.clear();
109
+ }
110
+ function computeBackoffMs(attempt, baseMs, maxMs) {
111
+ const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
112
+ const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
113
+ return exponential + jitter;
114
+ }
115
+ async function handleFrame(socket, raw, localPort, abortControllers) {
116
+ const frame = JSON.parse(raw);
117
+ if (frame.type === "http.cancel") {
118
+ abortControllers.get(frame.id)?.abort();
119
+ abortControllers.delete(frame.id);
120
+ return;
121
+ }
122
+ if (frame.type !== "http.request") {
123
+ return;
124
+ }
125
+ const abortController = new AbortController();
126
+ abortControllers.set(frame.id, abortController);
127
+ try {
128
+ const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
129
+ method: frame.method,
130
+ headers: frame.headers ?? {},
131
+ body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
132
+ signal: abortController.signal
133
+ });
134
+ const headers = Object.fromEntries(response.headers.entries());
135
+ const contentType = response.headers.get("content-type") ?? "";
136
+ if (response.body && contentType.includes("text/event-stream")) {
137
+ socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
138
+ const reader = response.body.getReader();
139
+ while (true) {
140
+ const next = await reader.read();
141
+ if (next.done) {
142
+ break;
143
+ }
144
+ socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
145
+ }
146
+ socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
147
+ return;
148
+ }
149
+ const body = Buffer.from(await response.arrayBuffer()).toString("base64");
150
+ socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
151
+ } catch (error) {
152
+ const message = error instanceof Error ? error.message : "Relay request failed";
153
+ socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
154
+ } finally {
155
+ abortControllers.delete(frame.id);
156
+ }
157
+ }
158
+
159
+ // src/daemon/service.ts
160
+ async function startLinkService(options = {}) {
161
+ const paths = options.paths ?? resolveRuntimePaths();
162
+ const logger = createFileLogger({ paths });
163
+ const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
164
+ await logger.info("service_starting", {
165
+ port: config.port,
166
+ mode: identity?.link_id ? "paired" : "local-only"
167
+ });
168
+ const app = await createApp({ paths, logger, onPairingClaimed: options.onPairingClaimed });
169
+ const server = app.listen(config.port);
170
+ server.on("error", (error) => {
171
+ void logger.error("service_error", { error: error.message });
172
+ });
173
+ void logger.info("service_started", {
174
+ port: config.port,
175
+ link_id: identity?.link_id ?? null
176
+ });
177
+ let relay = null;
178
+ if (identity?.link_id) {
179
+ relay = connectRelayControl({
180
+ relayBaseUrl: config.relayBaseUrl,
181
+ linkId: identity.link_id,
182
+ localPort: config.port,
183
+ maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
184
+ backoffBaseMs: 1e3,
185
+ backoffMaxMs: 3e4,
186
+ onStatus: (status) => {
187
+ void logger.info("relay_status", status);
188
+ }
189
+ });
190
+ } else {
191
+ void logger.info("relay_skipped", { reason: "link_not_paired" });
192
+ }
193
+ if (options.writePidFile) {
194
+ await writePidFile(paths);
195
+ }
196
+ return {
197
+ async close() {
198
+ relay?.close();
199
+ await closeServer(server);
200
+ await logger.info("service_stopped");
201
+ await logger.flush();
202
+ if (options.writePidFile) {
203
+ await rm(pidFilePath(paths), { force: true }).catch(() => void 0);
204
+ }
205
+ }
206
+ };
207
+ }
208
+ function pidFilePath(paths = resolveRuntimePaths()) {
209
+ return `${paths.runDir}/hermeslink.pid`;
210
+ }
211
+ async function writePidFile(paths) {
212
+ await mkdir(paths.runDir, { recursive: true, mode: 448 });
213
+ await writeFile(pidFilePath(paths), `${process.pid}
214
+ `, { mode: 384 });
215
+ }
216
+ async function closeServer(server) {
217
+ await new Promise((resolve, reject) => {
218
+ server.close((error) => {
219
+ if (error) {
220
+ reject(error);
221
+ return;
222
+ }
223
+ resolve();
224
+ });
225
+ });
226
+ }
227
+
228
+ // src/daemon/process.ts
229
+ async function startDaemonProcess(paths = resolveRuntimePaths()) {
230
+ const status = await getDaemonStatus(paths);
231
+ if (status.running) {
232
+ return status;
233
+ }
234
+ await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
235
+ await mkdir2(paths.runDir, { recursive: true, mode: 448 });
236
+ const log = await open(daemonLogFile(paths), "a", 384);
237
+ const scriptPath = currentCliScriptPath();
238
+ const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
239
+ detached: true,
240
+ stdio: ["ignore", log.fd, log.fd],
241
+ env: process.env
242
+ });
243
+ child.unref();
244
+ await log.close();
245
+ for (let index = 0; index < 12; index += 1) {
246
+ await wait(250);
247
+ const next = await getDaemonStatus(paths);
248
+ if (next.running) {
249
+ return next;
250
+ }
251
+ }
252
+ return await getDaemonStatus(paths);
253
+ }
254
+ async function stopDaemonProcess(paths = resolveRuntimePaths()) {
255
+ const status = await getDaemonStatus(paths);
256
+ if (!status.running || !status.pid) {
257
+ return status;
258
+ }
259
+ try {
260
+ process.kill(status.pid, "SIGTERM");
261
+ } catch {
262
+ await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
263
+ return await getDaemonStatus(paths);
264
+ }
265
+ for (let index = 0; index < 20; index += 1) {
266
+ await wait(250);
267
+ if (!isProcessAlive(status.pid)) {
268
+ break;
269
+ }
270
+ }
271
+ if (!isProcessAlive(status.pid)) {
272
+ await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
273
+ }
274
+ return await getDaemonStatus(paths);
275
+ }
276
+ async function getDaemonStatus(paths = resolveRuntimePaths()) {
277
+ const pidFile = pidFilePath(paths);
278
+ const pid = await readPid(pidFile);
279
+ if (pid && !isProcessAlive(pid)) {
280
+ await rm2(pidFile, { force: true }).catch(() => void 0);
281
+ return {
282
+ running: false,
283
+ pid: null,
284
+ pidFile,
285
+ logFile: daemonLogFile(paths)
286
+ };
287
+ }
288
+ return {
289
+ running: Boolean(pid),
290
+ pid,
291
+ pidFile,
292
+ logFile: daemonLogFile(paths)
293
+ };
294
+ }
295
+ function daemonLogFile(paths = resolveRuntimePaths()) {
296
+ return path.join(paths.logsDir, "daemon.log");
297
+ }
298
+ function currentCliScriptPath() {
299
+ return process.argv[1];
300
+ }
301
+ async function readPid(filePath) {
302
+ const raw = await readFile(filePath, "utf8").catch(() => null);
303
+ if (!raw) {
304
+ return null;
305
+ }
306
+ const pid = Number.parseInt(raw.trim(), 10);
307
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
308
+ }
309
+ function isProcessAlive(pid) {
310
+ try {
311
+ process.kill(pid, 0);
312
+ return true;
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+ function wait(ms) {
318
+ return new Promise((resolve) => setTimeout(resolve, ms));
319
+ }
320
+
321
+ // src/autostart/autostart.ts
322
+ var execFileAsync = promisify(execFile);
323
+ var MACOS_LABEL = "com.hermespilot.link";
324
+ async function enableAutostart() {
325
+ const definition = await resolveAutostartDefinition();
326
+ if (!definition) {
327
+ return unsupportedStatus();
328
+ }
329
+ await mkdir3(path2.dirname(definition.filePath), { recursive: true, mode: 448 });
330
+ await writeFile2(definition.filePath, definition.content, { mode: 384 });
331
+ if (definition.method === "systemd-user") {
332
+ await execFileAsync("systemctl", ["--user", "enable", path2.basename(definition.filePath)]).catch(async () => {
333
+ await rm3(definition.filePath, { force: true }).catch(() => void 0);
334
+ const fallback = xdgAutostartDefinition();
335
+ await mkdir3(path2.dirname(fallback.filePath), { recursive: true, mode: 448 });
336
+ await writeFile2(fallback.filePath, fallback.content, { mode: 384 });
337
+ });
338
+ }
339
+ return await getAutostartStatus();
340
+ }
341
+ async function disableAutostart() {
342
+ const definitions = await allAutostartDefinitions();
343
+ for (const definition of definitions) {
344
+ if (definition.method === "systemd-user") {
345
+ await execFileAsync("systemctl", ["--user", "disable", path2.basename(definition.filePath)]).catch(() => void 0);
346
+ }
347
+ await rm3(definition.filePath, { force: true }).catch(() => void 0);
348
+ }
349
+ return await getAutostartStatus();
350
+ }
351
+ async function getAutostartStatus() {
352
+ const definitions = await allAutostartDefinitions();
353
+ if (definitions.length === 0) {
354
+ return unsupportedStatus();
355
+ }
356
+ for (const definition of definitions) {
357
+ const content = await readFile2(definition.filePath, "utf8").catch(() => null);
358
+ if (content !== null) {
359
+ return {
360
+ supported: true,
361
+ enabled: true,
362
+ method: definition.method,
363
+ filePath: definition.filePath
364
+ };
365
+ }
366
+ }
367
+ const primary = definitions[0];
368
+ return {
369
+ supported: true,
370
+ enabled: false,
371
+ method: primary.method,
372
+ filePath: primary.filePath
373
+ };
374
+ }
375
+ async function resolveAutostartDefinition() {
376
+ if (process.platform === "darwin") {
377
+ return launchdDefinition();
378
+ }
379
+ if (process.platform === "win32") {
380
+ return windowsStartupDefinition();
381
+ }
382
+ if (process.platform === "linux") {
383
+ return await hasSystemctlUser() ? systemdUserDefinition() : xdgAutostartDefinition();
384
+ }
385
+ return null;
386
+ }
387
+ async function allAutostartDefinitions() {
388
+ if (process.platform === "darwin") {
389
+ return [launchdDefinition()];
390
+ }
391
+ if (process.platform === "win32") {
392
+ return [windowsStartupDefinition()];
393
+ }
394
+ if (process.platform === "linux") {
395
+ return [systemdUserDefinition(), xdgAutostartDefinition()];
396
+ }
397
+ return [];
398
+ }
399
+ async function hasSystemctlUser() {
400
+ try {
401
+ await execFileAsync("systemctl", ["--user", "show-environment"]);
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+ function launchdDefinition() {
408
+ const filePath = path2.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
409
+ return {
410
+ method: "launchd",
411
+ filePath,
412
+ content: `<?xml version="1.0" encoding="UTF-8"?>
413
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
414
+ <plist version="1.0">
415
+ <dict>
416
+ <key>Label</key>
417
+ <string>${MACOS_LABEL}</string>
418
+ <key>ProgramArguments</key>
419
+ <array>
420
+ <string>${xmlEscape(process.execPath)}</string>
421
+ <string>${xmlEscape(currentCliScriptPath())}</string>
422
+ <string>daemon</string>
423
+ <string>--foreground</string>
424
+ </array>
425
+ <key>RunAtLoad</key>
426
+ <true/>
427
+ <key>KeepAlive</key>
428
+ <false/>
429
+ <key>StandardOutPath</key>
430
+ <string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
431
+ <key>StandardErrorPath</key>
432
+ <string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
433
+ </dict>
434
+ </plist>
435
+ `
436
+ };
437
+ }
438
+ function systemdUserDefinition() {
439
+ const filePath = path2.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
440
+ return {
441
+ method: "systemd-user",
442
+ filePath,
443
+ content: `[Unit]
444
+ Description=Hermes Link
445
+ After=network-online.target
446
+
447
+ [Service]
448
+ Type=simple
449
+ ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon --foreground
450
+ Restart=no
451
+
452
+ [Install]
453
+ WantedBy=default.target
454
+ `
455
+ };
456
+ }
457
+ function xdgAutostartDefinition() {
458
+ const filePath = path2.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
459
+ return {
460
+ method: "xdg-autostart",
461
+ filePath,
462
+ content: `[Desktop Entry]
463
+ Type=Application
464
+ Name=Hermes Link
465
+ Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon --foreground
466
+ Terminal=false
467
+ X-GNOME-Autostart-enabled=true
468
+ `
469
+ };
470
+ }
471
+ function windowsStartupDefinition() {
472
+ const appData = process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming");
473
+ const filePath = path2.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
474
+ return {
475
+ method: "windows-startup",
476
+ filePath,
477
+ content: `@echo off\r
478
+ start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon --foreground\r
479
+ `
480
+ };
481
+ }
482
+ function unsupportedStatus() {
483
+ return {
484
+ supported: false,
485
+ enabled: false,
486
+ method: "unsupported",
487
+ filePath: null
488
+ };
489
+ }
490
+ function xmlEscape(value) {
491
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
492
+ }
493
+ function systemdQuote(value) {
494
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
495
+ }
496
+ function desktopQuote(value) {
497
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
498
+ }
499
+
19
500
  // src/i18n.ts
20
501
  var messages = {
21
502
  en: {
@@ -29,10 +510,30 @@ var messages = {
29
510
  "status.linkId": "Link ID: {value}",
30
511
  "status.notPaired": "not paired",
31
512
  "start.description": "Start Hermes Link daemon",
513
+ "start.backgroundStarted": "Hermes Link is running in the background. PID: {pid}",
514
+ "start.alreadyRunning": "Hermes Link is already running. PID: {pid}",
32
515
  "start.notPaired": "Hermes Link is not paired yet. Starting in local-only maintenance mode.",
33
516
  "start.notPaired.detail": "Relay, Server polling, and LAN entrypoints stay disabled until you run `hermeslink pair`.",
34
517
  "start.listening": "Hermes Link API listening on http://127.0.0.1:{port}",
35
518
  "start.relayConnecting": "Relay control connecting for {linkId}",
519
+ "stop.description": "Stop the background Hermes Link daemon",
520
+ "stop.stopped": "Hermes Link stopped.",
521
+ "stop.notRunning": "Hermes Link is not running.",
522
+ "restart.description": "Restart the background Hermes Link daemon",
523
+ "daemon.description": "Run Hermes Link in the foreground",
524
+ "daemon.foreground": "Hermes Link foreground daemon is running. Press Ctrl+C to stop.",
525
+ "logs.description": "Show Hermes Link log paths",
526
+ "logs.servicePath": "Service log: {path}",
527
+ "logs.daemonPath": "Daemon stdout/stderr log: {path}",
528
+ "autostart.description": "Manage boot autostart",
529
+ "autostart.on.description": "Enable boot autostart",
530
+ "autostart.off.description": "Disable boot autostart",
531
+ "autostart.status.description": "Show boot autostart status",
532
+ "autostart.enabled": "Boot autostart enabled via {method}: {path}",
533
+ "autostart.disabled": "Boot autostart disabled.",
534
+ "autostart.status.enabled": "Boot autostart: enabled via {method}: {path}",
535
+ "autostart.status.disabled": "Boot autostart: disabled. Method: {method}. File: {path}",
536
+ "autostart.unsupported": "Boot autostart is not supported on this platform yet.",
36
537
  "pair.description": "Create a Hermes Link pairing session",
37
538
  "pair.preparing": "Preparing pairing session through HermesPilot Server and Relay...",
38
539
  "pair.server": "Server: {url}",
@@ -42,6 +543,8 @@ var messages = {
42
543
  "pair.localApi": "Local API: http://127.0.0.1:{port}",
43
544
  "pair.scan": "Scan this QR code with the HermesPilot App:",
44
545
  "pair.expires": "Pairing expires in 10 minutes. Press Ctrl+C to stop Hermes Link.",
546
+ "pair.claimed": "Pairing succeeded. Starting Hermes Link in the background...",
547
+ "pair.autostartFailed": "Pairing succeeded, but boot autostart could not be enabled: {message}",
45
548
  "doctor.description": "Run local diagnostics",
46
549
  "doctor.identityOk": "Runtime identity: OK",
47
550
  "doctor.installId": "Install ID: {value}",
@@ -66,10 +569,30 @@ var messages = {
66
569
  "status.linkId": "Link ID\uFF1A{value}",
67
570
  "status.notPaired": "\u5C1A\u672A\u914D\u5BF9",
68
571
  "start.description": "\u542F\u52A8 Hermes Link \u670D\u52A1",
572
+ "start.backgroundStarted": "Hermes Link \u5DF2\u5728\u540E\u53F0\u8FD0\u884C\u3002PID\uFF1A{pid}",
573
+ "start.alreadyRunning": "Hermes Link \u5DF2\u7ECF\u5728\u8FD0\u884C\u3002PID\uFF1A{pid}",
69
574
  "start.notPaired": "Hermes Link \u8FD8\u6CA1\u6709\u914D\u5BF9\uFF0C\u5C06\u4EE5\u672C\u5730\u7EF4\u62A4\u6A21\u5F0F\u542F\u52A8\u3002",
70
575
  "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
576
  "start.listening": "Hermes Link API \u6B63\u5728\u76D1\u542C http://127.0.0.1:{port}",
72
577
  "start.relayConnecting": "\u6B63\u5728\u4E3A {linkId} \u8FDE\u63A5 Relay \u63A7\u5236\u901A\u9053",
578
+ "stop.description": "\u505C\u6B62\u540E\u53F0 Hermes Link \u670D\u52A1",
579
+ "stop.stopped": "Hermes Link \u5DF2\u505C\u6B62\u3002",
580
+ "stop.notRunning": "Hermes Link \u6CA1\u6709\u5728\u8FD0\u884C\u3002",
581
+ "restart.description": "\u91CD\u542F\u540E\u53F0 Hermes Link \u670D\u52A1",
582
+ "daemon.description": "\u4EE5\u524D\u53F0\u65B9\u5F0F\u8FD0\u884C Hermes Link",
583
+ "daemon.foreground": "Hermes Link \u524D\u53F0\u670D\u52A1\u6B63\u5728\u8FD0\u884C\u3002\u6309 Ctrl+C \u505C\u6B62\u3002",
584
+ "logs.description": "\u663E\u793A Hermes Link \u65E5\u5FD7\u8DEF\u5F84",
585
+ "logs.servicePath": "\u670D\u52A1\u65E5\u5FD7\uFF1A{path}",
586
+ "logs.daemonPath": "Daemon \u6807\u51C6\u8F93\u51FA/\u9519\u8BEF\u65E5\u5FD7\uFF1A{path}",
587
+ "autostart.description": "\u7BA1\u7406\u5F00\u673A\u81EA\u542F",
588
+ "autostart.on.description": "\u542F\u7528\u5F00\u673A\u81EA\u542F",
589
+ "autostart.off.description": "\u5173\u95ED\u5F00\u673A\u81EA\u542F",
590
+ "autostart.status.description": "\u67E5\u770B\u5F00\u673A\u81EA\u542F\u72B6\u6001",
591
+ "autostart.enabled": "\u5DF2\u542F\u7528\u5F00\u673A\u81EA\u542F\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
592
+ "autostart.disabled": "\u5DF2\u5173\u95ED\u5F00\u673A\u81EA\u542F\u3002",
593
+ "autostart.status.enabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
594
+ "autostart.status.disabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u672A\u542F\u7528\u3002\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
595
+ "autostart.unsupported": "\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u5F00\u673A\u81EA\u542F\u3002",
73
596
  "pair.description": "\u521B\u5EFA Hermes Link \u914D\u5BF9\u4F1A\u8BDD",
74
597
  "pair.preparing": "\u6B63\u5728\u901A\u8FC7 HermesPilot Server \u548C Relay \u521B\u5EFA\u914D\u5BF9\u4F1A\u8BDD...",
75
598
  "pair.server": "Server\uFF1A{url}",
@@ -79,6 +602,8 @@ var messages = {
79
602
  "pair.localApi": "\u672C\u5730 API\uFF1Ahttp://127.0.0.1:{port}",
80
603
  "pair.scan": "\u8BF7\u4F7F\u7528 HermesPilot App \u626B\u63CF\u8FD9\u4E2A\u4E8C\u7EF4\u7801\uFF1A",
81
604
  "pair.expires": "\u914D\u5BF9\u4F1A\u8BDD 10 \u5206\u949F\u540E\u8FC7\u671F\u3002\u6309 Ctrl+C \u505C\u6B62 Hermes Link\u3002",
605
+ "pair.claimed": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002\u6B63\u5728\u628A Hermes Link \u5207\u6362\u5230\u540E\u53F0\u8FD0\u884C...",
606
+ "pair.autostartFailed": "\u914D\u5BF9\u5DF2\u6210\u529F\uFF0C\u4F46\u542F\u7528\u5F00\u673A\u81EA\u542F\u5931\u8D25\uFF1A{message}",
82
607
  "doctor.description": "\u8FD0\u884C\u672C\u673A\u8BCA\u65AD",
83
608
  "doctor.identityOk": "\u8FD0\u884C\u8EAB\u4EFD\uFF1A\u6B63\u5E38",
84
609
  "doctor.installId": "Install ID\uFF1A{value}",
@@ -166,83 +691,6 @@ function parseLanguage(value) {
166
691
  return null;
167
692
  }
168
693
 
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
694
  // src/cli/index.ts
247
695
  var program = new Command();
248
696
  var helpLanguage = detectSystemLanguage();
@@ -276,26 +724,44 @@ program.command("status").option("--json", helpText("status.json")).description(
276
724
  console.log(t("status.linkId", { value: payload.identity?.linkId ?? t("status.notPaired") }));
277
725
  });
278
726
  program.command("start").description(helpText("start.description")).action(async () => {
279
- const [identity, config] = await Promise.all([loadIdentity(), loadConfig()]);
727
+ const [config, status] = await Promise.all([loadConfig(), getDaemonStatus()]);
280
728
  const language = resolveLanguage(config.language);
281
729
  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 }));
730
+ if (status.running && status.pid) {
731
+ console.log(t("start.alreadyRunning", { pid: status.pid }));
732
+ return;
295
733
  }
734
+ const nextStatus = await startDaemonProcess();
735
+ console.log(t("start.backgroundStarted", { pid: nextStatus.pid ?? "unknown" }));
736
+ });
737
+ program.command("stop").description(helpText("stop.description")).action(async () => {
738
+ const config = await loadConfig();
739
+ const language = resolveLanguage(config.language);
740
+ const t = translate.bind(null, language);
741
+ const before = await getDaemonStatus();
742
+ if (!before.running) {
743
+ console.log(t("stop.notRunning"));
744
+ return;
745
+ }
746
+ await stopDaemonProcess();
747
+ console.log(t("stop.stopped"));
748
+ });
749
+ program.command("restart").description(helpText("restart.description")).action(async () => {
750
+ const config = await loadConfig();
751
+ const language = resolveLanguage(config.language);
752
+ const t = translate.bind(null, language);
753
+ await stopDaemonProcess();
754
+ const status = await startDaemonProcess();
755
+ console.log(t("start.backgroundStarted", { pid: status.pid ?? "unknown" }));
756
+ });
757
+ program.command("daemon").option("--foreground", "run in foreground").description(helpText("daemon.description")).action(async () => {
758
+ const config = await loadConfig();
759
+ const language = resolveLanguage(config.language);
760
+ const t = translate.bind(null, language);
761
+ const service = await startLinkService({ writePidFile: true });
762
+ console.log(t("daemon.foreground"));
296
763
  await waitForShutdown(async () => {
297
- relay?.close();
298
- await new Promise((resolve) => server.close(() => resolve()));
764
+ await service.close();
299
765
  });
300
766
  });
301
767
  program.command("pair").description(helpText("pair.description")).action(async () => {
@@ -308,11 +774,10 @@ program.command("pair").description(helpText("pair.description")).action(async (
308
774
  console.log(t("pair.relay", { url: config.relayBaseUrl }));
309
775
  await ensureIdentity(paths);
310
776
  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
777
+ const pairingClaimed = createDeferred();
778
+ const service = await startLinkService({
779
+ paths,
780
+ onPairingClaimed: () => pairingClaimed.resolve()
316
781
  });
317
782
  const qrValue = JSON.stringify(prepared.qrPayload);
318
783
  console.log(t("pair.linkId", { value: prepared.linkId }));
@@ -321,10 +786,65 @@ program.command("pair").description(helpText("pair.description")).action(async (
321
786
  console.log(t("pair.scan"));
322
787
  qrcode.generate(qrValue, { small: true });
323
788
  console.log(t("pair.expires"));
324
- await waitForShutdown(async () => {
325
- relay.close();
326
- await new Promise((resolve) => server.close(() => resolve()));
327
- });
789
+ const result = await waitForPairingOrShutdown(pairingClaimed.promise);
790
+ await service.close();
791
+ if (result === "claimed") {
792
+ console.log(t("pair.claimed"));
793
+ try {
794
+ const autostart2 = await enableAutostart();
795
+ if (autostart2.supported && autostart2.enabled) {
796
+ console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
797
+ }
798
+ } catch (error) {
799
+ const message = error instanceof Error ? error.message : String(error);
800
+ console.log(t("pair.autostartFailed", { message }));
801
+ }
802
+ const status = await startDaemonProcess(paths);
803
+ console.log(t("start.backgroundStarted", { pid: status.pid ?? "unknown" }));
804
+ }
805
+ });
806
+ var autostart = program.command("autostart").description(helpText("autostart.description"));
807
+ autostart.command("on").description(helpText("autostart.on.description")).action(async () => {
808
+ const config = await loadConfig();
809
+ const language = resolveLanguage(config.language);
810
+ const t = translate.bind(null, language);
811
+ const status = await enableAutostart();
812
+ if (!status.supported) {
813
+ console.log(t("autostart.unsupported"));
814
+ return;
815
+ }
816
+ console.log(t("autostart.enabled", { method: status.method, path: status.filePath ?? "" }));
817
+ });
818
+ autostart.command("off").description(helpText("autostart.off.description")).action(async () => {
819
+ const config = await loadConfig();
820
+ const language = resolveLanguage(config.language);
821
+ const t = translate.bind(null, language);
822
+ await disableAutostart();
823
+ console.log(t("autostart.disabled"));
824
+ });
825
+ autostart.command("status").description(helpText("autostart.status.description")).action(async () => {
826
+ const config = await loadConfig();
827
+ const language = resolveLanguage(config.language);
828
+ const t = translate.bind(null, language);
829
+ const status = await getAutostartStatus();
830
+ if (!status.supported) {
831
+ console.log(t("autostart.unsupported"));
832
+ return;
833
+ }
834
+ console.log(
835
+ t(status.enabled ? "autostart.status.enabled" : "autostart.status.disabled", {
836
+ method: status.method,
837
+ path: status.filePath ?? ""
838
+ })
839
+ );
840
+ });
841
+ program.command("logs").description(helpText("logs.description")).action(async () => {
842
+ const paths = resolveRuntimePaths();
843
+ const config = await loadConfig(paths);
844
+ const language = resolveLanguage(config.language);
845
+ const t = translate.bind(null, language);
846
+ console.log(t("logs.servicePath", { path: getLinkLogFile(paths) }));
847
+ console.log(t("logs.daemonPath", { path: daemonLogFile(paths) }));
328
848
  });
329
849
  program.command("doctor").description(helpText("doctor.description")).action(async () => {
330
850
  const [identity, config] = await Promise.all([ensureIdentity(), loadConfig()]);
@@ -350,10 +870,6 @@ async function loadCliLanguage() {
350
870
  const config = await loadConfig();
351
871
  return resolveLanguage(config.language);
352
872
  }
353
- async function startHttpServer(port) {
354
- const app = await createApp();
355
- return app.listen(port);
356
- }
357
873
  async function waitForShutdown(cleanup) {
358
874
  await new Promise((resolve) => {
359
875
  const stop = () => resolve();
@@ -362,4 +878,25 @@ async function waitForShutdown(cleanup) {
362
878
  });
363
879
  await cleanup();
364
880
  }
881
+ async function waitForPairingOrShutdown(pairingClaimed) {
882
+ let stop = null;
883
+ const shutdown = new Promise((resolve) => {
884
+ stop = () => resolve("shutdown");
885
+ process.once("SIGINT", stop);
886
+ process.once("SIGTERM", stop);
887
+ });
888
+ const result = await Promise.race([pairingClaimed.then(() => "claimed"), shutdown]);
889
+ if (stop) {
890
+ process.off("SIGINT", stop);
891
+ process.off("SIGTERM", stop);
892
+ }
893
+ return result;
894
+ }
895
+ function createDeferred() {
896
+ let resolve;
897
+ const promise = new Promise((innerResolve) => {
898
+ resolve = innerResolve;
899
+ });
900
+ return { promise, resolve };
901
+ }
365
902
  //# sourceMappingURL=index.js.map