@hermespilot/link 0.1.9 → 0.2.1

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
@@ -2,20 +2,31 @@
2
2
  import {
3
3
  LINK_COMMAND,
4
4
  LINK_VERSION,
5
+ LinkHttpError,
5
6
  clearPairingClaim,
6
- createApp,
7
- createFileLogger,
7
+ currentCliScriptPath,
8
+ daemonLogFile,
8
9
  ensureHermesApiServerAvailable,
9
10
  ensureHermesApiServerConfig,
10
11
  ensureIdentity,
12
+ getDaemonStatus,
11
13
  getIdentityStatus,
12
14
  getLinkLogFile,
15
+ hasActiveDevices,
13
16
  loadConfig,
14
17
  loadIdentity,
15
18
  preparePairing,
19
+ probeLocalLinkService,
20
+ readHermesApiServerConfig,
16
21
  readPairingClaim,
17
- resolveRuntimePaths
18
- } from "../chunk-QAPDGN52.js";
22
+ resolveHermesConfigPath,
23
+ resolveHermesProfileDir,
24
+ resolveRuntimePaths,
25
+ runDaemonSupervisor,
26
+ startDaemonProcess,
27
+ startLinkService,
28
+ stopDaemonProcess
29
+ } from "../chunk-YQX7OQFH.js";
19
30
 
20
31
  // src/cli/index.ts
21
32
  import { Command } from "commander";
@@ -23,366 +34,10 @@ import qrcode from "qrcode-terminal";
23
34
 
24
35
  // src/autostart/autostart.ts
25
36
  import { execFile } from "child_process";
26
- import { mkdir as mkdir3, readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
37
+ import { mkdir, readFile, rm, writeFile } from "fs/promises";
27
38
  import os from "os";
28
- import path2 from "path";
29
- import { promisify } from "util";
30
-
31
- // src/daemon/process.ts
32
- import { spawn } from "child_process";
33
- import { mkdir as mkdir2, open, readFile, rm as rm2 } from "fs/promises";
34
39
  import path from "path";
35
-
36
- // src/daemon/service.ts
37
- import { createServer } from "http";
38
- import { mkdir, rm, writeFile } from "fs/promises";
39
-
40
- // src/relay/control-client.ts
41
- import WebSocket from "ws";
42
- function connectRelayControl(options) {
43
- const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
44
- wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
45
- wsUrl.searchParams.set("link_id", options.linkId);
46
- const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
47
- const backoffBaseMs = options.backoffBaseMs ?? 1e3;
48
- const backoffMaxMs = options.backoffMaxMs ?? 3e4;
49
- let reconnectAttempts = 0;
50
- let closedByUser = false;
51
- let socket = null;
52
- let retryTimer = null;
53
- let abortControllers = /* @__PURE__ */ new Map();
54
- const connect = () => {
55
- options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
56
- socket = new WebSocket(wsUrl, {
57
- headers: {
58
- "x-hermes-link-version": LINK_VERSION
59
- }
60
- });
61
- socket.on("open", () => {
62
- reconnectAttempts = 0;
63
- options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
64
- });
65
- socket.on("message", (raw) => {
66
- if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
67
- return;
68
- }
69
- void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
70
- const message = error instanceof Error ? error.message : "Relay request failed";
71
- socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
72
- });
73
- });
74
- socket.on("error", (error) => {
75
- const message = error instanceof Error ? error.message : "Relay websocket error";
76
- options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts, message });
77
- });
78
- socket.on("close", () => {
79
- abortAll(abortControllers);
80
- abortControllers = /* @__PURE__ */ new Map();
81
- if (closedByUser) {
82
- options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
83
- return;
84
- }
85
- if (reconnectAttempts >= maxReconnectAttempts) {
86
- options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
87
- return;
88
- }
89
- reconnectAttempts += 1;
90
- const delay = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
91
- options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay}ms` });
92
- retryTimer = setTimeout(connect, delay);
93
- retryTimer.unref?.();
94
- });
95
- };
96
- connect();
97
- return {
98
- close() {
99
- closedByUser = true;
100
- if (retryTimer) {
101
- clearTimeout(retryTimer);
102
- }
103
- abortAll(abortControllers);
104
- socket?.close();
105
- }
106
- };
107
- }
108
- function abortAll(abortControllers) {
109
- for (const controller of abortControllers.values()) {
110
- controller.abort();
111
- }
112
- abortControllers.clear();
113
- }
114
- function computeBackoffMs(attempt, baseMs, maxMs) {
115
- const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
116
- const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
117
- return exponential + jitter;
118
- }
119
- async function handleFrame(socket, raw, localPort, abortControllers) {
120
- const frame = JSON.parse(raw);
121
- if (frame.type === "http.cancel") {
122
- abortControllers.get(frame.id)?.abort();
123
- abortControllers.delete(frame.id);
124
- return;
125
- }
126
- if (frame.type !== "http.request") {
127
- return;
128
- }
129
- const abortController = new AbortController();
130
- abortControllers.set(frame.id, abortController);
131
- try {
132
- const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
133
- method: frame.method,
134
- headers: frame.headers ?? {},
135
- body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
136
- signal: abortController.signal
137
- });
138
- const headers = Object.fromEntries(response.headers.entries());
139
- const contentType = response.headers.get("content-type") ?? "";
140
- if (response.body && contentType.includes("text/event-stream")) {
141
- socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
142
- const reader = response.body.getReader();
143
- while (true) {
144
- const next = await reader.read();
145
- if (next.done) {
146
- break;
147
- }
148
- socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
149
- }
150
- socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
151
- return;
152
- }
153
- const body = Buffer.from(await response.arrayBuffer()).toString("base64");
154
- socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
155
- } catch (error) {
156
- const message = error instanceof Error ? error.message : "Relay request failed";
157
- socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
158
- } finally {
159
- abortControllers.delete(frame.id);
160
- }
161
- }
162
-
163
- // src/daemon/service.ts
164
- async function startLinkService(options = {}) {
165
- const paths = options.paths ?? resolveRuntimePaths();
166
- const logger = createFileLogger({ paths });
167
- const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
168
- await logger.info("service_starting", {
169
- port: config.port,
170
- mode: identity?.link_id ? "paired" : "local-only"
171
- });
172
- const app = await createApp({ paths, logger, onPairingClaimed: options.onPairingClaimed });
173
- const server = createServer(app.callback());
174
- try {
175
- await listenServer(server, config.port);
176
- } catch (error) {
177
- await logger.error("service_start_failed", {
178
- port: config.port,
179
- error: error instanceof Error ? error.message : String(error)
180
- });
181
- await logger.flush();
182
- throw error;
183
- }
184
- server.on("error", (error) => {
185
- void logger.error("service_error", { error: error.message });
186
- });
187
- void logger.info("service_started", {
188
- port: config.port,
189
- link_id: identity?.link_id ?? null
190
- });
191
- let relay = null;
192
- if (identity?.link_id) {
193
- relay = connectRelayControl({
194
- relayBaseUrl: config.relayBaseUrl,
195
- linkId: identity.link_id,
196
- localPort: config.port,
197
- maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
198
- backoffBaseMs: 1e3,
199
- backoffMaxMs: 3e4,
200
- onStatus: (status) => {
201
- void logger.info("relay_status", status);
202
- }
203
- });
204
- } else {
205
- void logger.info("relay_skipped", { reason: "link_not_paired" });
206
- }
207
- if (options.writePidFile) {
208
- await writePidFile(paths);
209
- }
210
- return {
211
- async close() {
212
- relay?.close();
213
- await closeServer(server);
214
- await logger.info("service_stopped");
215
- await logger.flush();
216
- if (options.writePidFile) {
217
- await rm(pidFilePath(paths), { force: true }).catch(() => void 0);
218
- }
219
- }
220
- };
221
- }
222
- function pidFilePath(paths = resolveRuntimePaths()) {
223
- return `${paths.runDir}/hermeslink.pid`;
224
- }
225
- async function writePidFile(paths) {
226
- await mkdir(paths.runDir, { recursive: true, mode: 448 });
227
- await writeFile(pidFilePath(paths), `${process.pid}
228
- `, { mode: 384 });
229
- }
230
- async function closeServer(server) {
231
- await new Promise((resolve, reject) => {
232
- server.close((error) => {
233
- if (error) {
234
- reject(error);
235
- return;
236
- }
237
- resolve();
238
- });
239
- });
240
- }
241
- async function listenServer(server, port) {
242
- await new Promise((resolve, reject) => {
243
- const cleanup = () => {
244
- server.off("error", onError);
245
- server.off("listening", onListening);
246
- };
247
- const onError = (error) => {
248
- cleanup();
249
- reject(error);
250
- };
251
- const onListening = () => {
252
- cleanup();
253
- resolve();
254
- };
255
- server.once("error", onError);
256
- server.once("listening", onListening);
257
- server.listen(port);
258
- });
259
- }
260
-
261
- // src/daemon/process.ts
262
- async function startDaemonProcess(paths = resolveRuntimePaths()) {
263
- const status = await getDaemonStatus(paths);
264
- if (status.running) {
265
- return status;
266
- }
267
- await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
268
- await mkdir2(paths.runDir, { recursive: true, mode: 448 });
269
- const log = await open(daemonLogFile(paths), "a", 384);
270
- const scriptPath = currentCliScriptPath();
271
- const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
272
- detached: true,
273
- stdio: ["ignore", log.fd, log.fd],
274
- env: process.env
275
- });
276
- child.unref();
277
- await log.close();
278
- for (let index = 0; index < 12; index += 1) {
279
- await wait(250);
280
- const next = await getDaemonStatus(paths);
281
- if (next.running) {
282
- return next;
283
- }
284
- }
285
- return await getDaemonStatus(paths);
286
- }
287
- async function probeLocalLinkService(options) {
288
- const unreachable = {
289
- reachable: false,
290
- reusable: false,
291
- linkId: null,
292
- version: null
293
- };
294
- let response;
295
- try {
296
- response = await fetch(`http://127.0.0.1:${options.port}/api/v1/bootstrap`, {
297
- headers: { accept: "application/json" },
298
- signal: AbortSignal.timeout(options.timeoutMs ?? 1e3)
299
- });
300
- } catch {
301
- return unreachable;
302
- }
303
- if (!response.ok) {
304
- return unreachable;
305
- }
306
- const payload = await response.json().catch(() => null);
307
- if (!payload || payload.api_version !== 1) {
308
- return unreachable;
309
- }
310
- const linkId = typeof payload.link_id === "string" ? payload.link_id : null;
311
- return {
312
- reachable: true,
313
- reusable: options.linkId ? linkId === options.linkId : true,
314
- linkId,
315
- version: typeof payload.version === "string" ? payload.version : null
316
- };
317
- }
318
- async function stopDaemonProcess(paths = resolveRuntimePaths()) {
319
- const status = await getDaemonStatus(paths);
320
- if (!status.running || !status.pid) {
321
- return status;
322
- }
323
- try {
324
- process.kill(status.pid, "SIGTERM");
325
- } catch {
326
- await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
327
- return await getDaemonStatus(paths);
328
- }
329
- for (let index = 0; index < 20; index += 1) {
330
- await wait(250);
331
- if (!isProcessAlive(status.pid)) {
332
- break;
333
- }
334
- }
335
- if (!isProcessAlive(status.pid)) {
336
- await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
337
- }
338
- return await getDaemonStatus(paths);
339
- }
340
- async function getDaemonStatus(paths = resolveRuntimePaths()) {
341
- const pidFile = pidFilePath(paths);
342
- const pid = await readPid(pidFile);
343
- if (pid && !isProcessAlive(pid)) {
344
- await rm2(pidFile, { force: true }).catch(() => void 0);
345
- return {
346
- running: false,
347
- pid: null,
348
- pidFile,
349
- logFile: daemonLogFile(paths)
350
- };
351
- }
352
- return {
353
- running: Boolean(pid),
354
- pid,
355
- pidFile,
356
- logFile: daemonLogFile(paths)
357
- };
358
- }
359
- function daemonLogFile(paths = resolveRuntimePaths()) {
360
- return path.join(paths.logsDir, "daemon.log");
361
- }
362
- function currentCliScriptPath() {
363
- return process.argv[1];
364
- }
365
- async function readPid(filePath) {
366
- const raw = await readFile(filePath, "utf8").catch(() => null);
367
- if (!raw) {
368
- return null;
369
- }
370
- const pid = Number.parseInt(raw.trim(), 10);
371
- return Number.isInteger(pid) && pid > 0 ? pid : null;
372
- }
373
- function isProcessAlive(pid) {
374
- try {
375
- process.kill(pid, 0);
376
- return true;
377
- } catch {
378
- return false;
379
- }
380
- }
381
- function wait(ms) {
382
- return new Promise((resolve) => setTimeout(resolve, ms));
383
- }
384
-
385
- // src/autostart/autostart.ts
40
+ import { promisify } from "util";
386
41
  var execFileAsync = promisify(execFile);
387
42
  var MACOS_LABEL = "com.hermespilot.link";
388
43
  async function enableAutostart() {
@@ -390,14 +45,14 @@ async function enableAutostart() {
390
45
  if (!definition) {
391
46
  return unsupportedStatus();
392
47
  }
393
- await mkdir3(path2.dirname(definition.filePath), { recursive: true, mode: 448 });
394
- await writeFile2(definition.filePath, definition.content, { mode: 384 });
48
+ await mkdir(path.dirname(definition.filePath), { recursive: true, mode: 448 });
49
+ await writeFile(definition.filePath, definition.content, { mode: 384 });
395
50
  if (definition.method === "systemd-user") {
396
- await execFileAsync("systemctl", ["--user", "enable", path2.basename(definition.filePath)]).catch(async () => {
397
- await rm3(definition.filePath, { force: true }).catch(() => void 0);
51
+ await execFileAsync("systemctl", ["--user", "enable", path.basename(definition.filePath)]).catch(async () => {
52
+ await rm(definition.filePath, { force: true }).catch(() => void 0);
398
53
  const fallback = xdgAutostartDefinition();
399
- await mkdir3(path2.dirname(fallback.filePath), { recursive: true, mode: 448 });
400
- await writeFile2(fallback.filePath, fallback.content, { mode: 384 });
54
+ await mkdir(path.dirname(fallback.filePath), { recursive: true, mode: 448 });
55
+ await writeFile(fallback.filePath, fallback.content, { mode: 384 });
401
56
  });
402
57
  }
403
58
  return await getAutostartStatus();
@@ -406,9 +61,9 @@ async function disableAutostart() {
406
61
  const definitions = await allAutostartDefinitions();
407
62
  for (const definition of definitions) {
408
63
  if (definition.method === "systemd-user") {
409
- await execFileAsync("systemctl", ["--user", "disable", path2.basename(definition.filePath)]).catch(() => void 0);
64
+ await execFileAsync("systemctl", ["--user", "disable", path.basename(definition.filePath)]).catch(() => void 0);
410
65
  }
411
- await rm3(definition.filePath, { force: true }).catch(() => void 0);
66
+ await rm(definition.filePath, { force: true }).catch(() => void 0);
412
67
  }
413
68
  return await getAutostartStatus();
414
69
  }
@@ -418,7 +73,7 @@ async function getAutostartStatus() {
418
73
  return unsupportedStatus();
419
74
  }
420
75
  for (const definition of definitions) {
421
- const content = await readFile2(definition.filePath, "utf8").catch(() => null);
76
+ const content = await readFile(definition.filePath, "utf8").catch(() => null);
422
77
  if (content !== null) {
423
78
  return {
424
79
  supported: true,
@@ -469,7 +124,7 @@ async function hasSystemctlUser() {
469
124
  }
470
125
  }
471
126
  function launchdDefinition() {
472
- const filePath = path2.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
127
+ const filePath = path.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
473
128
  return {
474
129
  method: "launchd",
475
130
  filePath,
@@ -483,24 +138,19 @@ function launchdDefinition() {
483
138
  <array>
484
139
  <string>${xmlEscape(process.execPath)}</string>
485
140
  <string>${xmlEscape(currentCliScriptPath())}</string>
486
- <string>daemon</string>
487
- <string>--foreground</string>
141
+ <string>daemon-supervisor</string>
488
142
  </array>
489
143
  <key>RunAtLoad</key>
490
144
  <true/>
491
145
  <key>KeepAlive</key>
492
146
  <false/>
493
- <key>StandardOutPath</key>
494
- <string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
495
- <key>StandardErrorPath</key>
496
- <string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
497
147
  </dict>
498
148
  </plist>
499
149
  `
500
150
  };
501
151
  }
502
152
  function systemdUserDefinition() {
503
- const filePath = path2.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
153
+ const filePath = path.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
504
154
  return {
505
155
  method: "systemd-user",
506
156
  filePath,
@@ -510,7 +160,7 @@ After=network-online.target
510
160
 
511
161
  [Service]
512
162
  Type=simple
513
- ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon --foreground
163
+ ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon-supervisor
514
164
  Restart=no
515
165
 
516
166
  [Install]
@@ -519,27 +169,27 @@ WantedBy=default.target
519
169
  };
520
170
  }
521
171
  function xdgAutostartDefinition() {
522
- const filePath = path2.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
172
+ const filePath = path.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
523
173
  return {
524
174
  method: "xdg-autostart",
525
175
  filePath,
526
176
  content: `[Desktop Entry]
527
177
  Type=Application
528
178
  Name=Hermes Link
529
- Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon --foreground
179
+ Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon-supervisor
530
180
  Terminal=false
531
181
  X-GNOME-Autostart-enabled=true
532
182
  `
533
183
  };
534
184
  }
535
185
  function windowsStartupDefinition() {
536
- const appData = process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming");
537
- const filePath = path2.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
186
+ const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
187
+ const filePath = path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
538
188
  return {
539
189
  method: "windows-startup",
540
190
  filePath,
541
191
  content: `@echo off\r
542
- start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon --foreground\r
192
+ start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon-supervisor\r
543
193
  `
544
194
  };
545
195
  }
@@ -598,7 +248,11 @@ var messages = {
598
248
  "autostart.status.enabled": "Boot autostart: enabled via {method}: {path}",
599
249
  "autostart.status.disabled": "Boot autostart: disabled. Method: {method}. File: {path}",
600
250
  "autostart.unsupported": "Boot autostart is not supported on this platform yet.",
251
+ "autostart.alreadyEnabled": "Boot autostart is already enabled via {method}: {path}",
601
252
  "pair.description": "Create a Hermes Link pairing session",
253
+ "pair.preflight": "Checking local Hermes configuration before pairing...",
254
+ "pair.hermesHome": "Hermes home: {path}",
255
+ "pair.apiReady": "Hermes API Server is ready on 127.0.0.1:{port}",
602
256
  "pair.preparing": "Preparing pairing session through HermesPilot Server and Relay...",
603
257
  "pair.server": "Server: {url}",
604
258
  "pair.relay": "Relay: {url}",
@@ -609,6 +263,7 @@ var messages = {
609
263
  "pair.expires": "Pairing expires in 10 minutes. Press Ctrl+C to cancel waiting.",
610
264
  "pair.claimed": "Pairing succeeded. Starting Hermes Link in the background...",
611
265
  "pair.claimedRunning": "Pairing succeeded. Hermes Link is already running in the background.",
266
+ "pair.autostartUnchanged": "Existing paired devices found. Boot autostart settings were left unchanged.",
612
267
  "pair.autostartFailed": "Pairing succeeded, but boot autostart could not be enabled: {message}",
613
268
  "doctor.description": "Run local diagnostics",
614
269
  "doctor.identityOk": "Runtime identity: OK",
@@ -662,7 +317,11 @@ var messages = {
662
317
  "autostart.status.enabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
663
318
  "autostart.status.disabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u672A\u542F\u7528\u3002\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
664
319
  "autostart.unsupported": "\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u5F00\u673A\u81EA\u542F\u3002",
320
+ "autostart.alreadyEnabled": "\u5F00\u673A\u81EA\u542F\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
665
321
  "pair.description": "\u521B\u5EFA Hermes Link \u914D\u5BF9\u4F1A\u8BDD",
322
+ "pair.preflight": "\u6B63\u5728\u914D\u5BF9\u524D\u68C0\u67E5\u672C\u673A Hermes \u914D\u7F6E...",
323
+ "pair.hermesHome": "Hermes \u6570\u636E\u76EE\u5F55\uFF1A{path}",
324
+ "pair.apiReady": "Hermes API Server \u5DF2\u5C31\u7EEA\uFF1A127.0.0.1:{port}",
666
325
  "pair.preparing": "\u6B63\u5728\u901A\u8FC7 HermesPilot Server \u548C Relay \u521B\u5EFA\u914D\u5BF9\u4F1A\u8BDD...",
667
326
  "pair.server": "Server\uFF1A{url}",
668
327
  "pair.relay": "Relay\uFF1A{url}",
@@ -673,6 +332,7 @@ var messages = {
673
332
  "pair.expires": "\u914D\u5BF9\u4F1A\u8BDD 10 \u5206\u949F\u540E\u8FC7\u671F\u3002\u6309 Ctrl+C \u9000\u51FA\u7B49\u5F85\u3002",
674
333
  "pair.claimed": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002\u6B63\u5728\u628A Hermes Link \u5207\u6362\u5230\u540E\u53F0\u8FD0\u884C...",
675
334
  "pair.claimedRunning": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002Hermes Link \u5DF2\u5728\u540E\u53F0\u6301\u7EED\u8FD0\u884C\u3002",
335
+ "pair.autostartUnchanged": "\u68C0\u6D4B\u5230\u5DF2\u6709\u914D\u5BF9\u8BBE\u5907\uFF0C\u5F00\u673A\u81EA\u542F\u8BBE\u7F6E\u4FDD\u6301\u4E0D\u53D8\u3002",
676
336
  "pair.autostartFailed": "\u914D\u5BF9\u5DF2\u6210\u529F\uFF0C\u4F46\u542F\u7528\u5F00\u673A\u81EA\u542F\u5931\u8D25\uFF1A{message}",
677
337
  "doctor.description": "\u8FD0\u884C\u672C\u673A\u8BCA\u65AD",
678
338
  "doctor.identityOk": "\u8FD0\u884C\u8EAB\u4EFD\uFF1A\u6B63\u5E38",
@@ -769,6 +429,134 @@ function parseLanguage(value) {
769
429
  return null;
770
430
  }
771
431
 
432
+ // src/pairing/preflight.ts
433
+ import { access, stat } from "fs/promises";
434
+ import path2 from "path";
435
+ async function assertPairingPreflightReady(options = {}) {
436
+ const profileName = normalizeProfileName(options.profileName);
437
+ const hermesHome = resolveHermesProfileDir(profileName);
438
+ const configPath = resolveHermesConfigPath(profileName);
439
+ const envPath = path2.join(hermesHome, ".env");
440
+ const failures = [];
441
+ if (!await isDirectory(hermesHome)) {
442
+ failures.push({
443
+ code: "hermes_home_missing",
444
+ zh: `\u6CA1\u6709\u627E\u5230\u5F53\u524D Hermes \u6570\u636E\u76EE\u5F55\uFF1A${hermesHome}`,
445
+ en: `Current Hermes home was not found: ${hermesHome}`,
446
+ actionZh: "\u8BF7\u5148\u8FD0\u884C `hermes setup` \u521D\u59CB\u5316 Hermes\uFF1B\u5982\u679C Hermes \u5728 Docker\u3001WSL \u6216\u53E6\u4E00\u4E2A Windows \u73AF\u5883\u4E2D\u8FD0\u884C\uFF0C\u8BF7\u5728\u542F\u52A8 Link \u524D\u8BBE\u7F6E HERMES_HOME \u6307\u5411 Link \u80FD\u8BBF\u95EE\u5230\u7684\u540C\u4E00\u4E2A\u76EE\u5F55\u3002",
447
+ actionEn: "Run `hermes setup` first. If Hermes runs in Docker, WSL, or another Windows environment, start Link with HERMES_HOME pointing to the same directory that Link can access."
448
+ });
449
+ }
450
+ if (!await isReadableFile(configPath)) {
451
+ failures.push({
452
+ code: "hermes_config_missing",
453
+ zh: `\u6CA1\u6709\u627E\u5230 Hermes \u914D\u7F6E\u6587\u4EF6\uFF1A${configPath}`,
454
+ en: `Hermes config file was not found: ${configPath}`,
455
+ actionZh: "\u8BF7\u5148\u8FD0\u884C `hermes setup` \u751F\u6210\u914D\u7F6E\uFF0C\u6216\u786E\u8BA4 Link \u4E0E Hermes \u4F7F\u7528\u7684\u662F\u540C\u4E00\u4E2A HERMES_HOME\u3002",
456
+ actionEn: "Run `hermes setup` to create the config, or make sure Link and Hermes use the same HERMES_HOME."
457
+ });
458
+ }
459
+ if (!await isReadableFile(envPath)) {
460
+ failures.push({
461
+ code: "hermes_env_missing",
462
+ zh: `\u6CA1\u6709\u627E\u5230 Hermes \u73AF\u5883\u914D\u7F6E\u6587\u4EF6\uFF1A${envPath}`,
463
+ en: `Hermes environment file was not found: ${envPath}`,
464
+ actionZh: "\u8BF7\u5148\u8FD0\u884C `hermes setup` \u521B\u5EFA `.env` \u5E76\u914D\u7F6E\u6A21\u578B/API Key\uFF1BLink \u9700\u8981\u80FD\u8BFB\u53D6\u5B83\uFF0C\u624D\u80FD\u590D\u7528 Hermes \u7684\u5B9E\u9645\u914D\u7F6E\u3002",
465
+ actionEn: "Run `hermes setup` to create `.env` and configure model/API keys. Link must be able to read it to reuse Hermes settings."
466
+ });
467
+ }
468
+ if (failures.length > 0) {
469
+ throwPairingPreflightError(failures);
470
+ }
471
+ const apiServerConfig = await readHermesApiServerConfig(profileName, configPath);
472
+ if (apiServerConfig.enabled !== true) {
473
+ throwPairingPreflightError([
474
+ {
475
+ code: "hermes_api_server_disabled",
476
+ zh: "Hermes API Server \u8FD8\u6CA1\u6709\u5F00\u542F\u3002",
477
+ en: "Hermes API Server is not enabled.",
478
+ actionZh: "\u8BF7\u8FD0\u884C `hermeslink doctor` \u8BA9 Link \u81EA\u52A8\u8865\u9F50 API Server \u914D\u7F6E\uFF0C\u6216\u5728 Hermes \u914D\u7F6E\u4E2D\u542F\u7528 platforms.api_server\u3002",
479
+ actionEn: "Run `hermeslink doctor` so Link can prepare the API Server config, or enable platforms.api_server in Hermes config."
480
+ }
481
+ ]);
482
+ }
483
+ try {
484
+ const ensureAvailable = options.ensureApiServerAvailable ?? ensureHermesApiServerAvailable;
485
+ const availability = await ensureAvailable({
486
+ paths: options.paths,
487
+ profileName,
488
+ fetchImpl: options.fetchImpl,
489
+ timeoutMs: 5e3,
490
+ autoStart: true
491
+ });
492
+ return {
493
+ profileName,
494
+ hermesHome,
495
+ configPath,
496
+ envPath,
497
+ apiServer: {
498
+ available: true,
499
+ started: availability.started,
500
+ host: availability.configResult.apiServer.host ?? null,
501
+ port: availability.configResult.apiServer.port ?? null
502
+ }
503
+ };
504
+ } catch (error) {
505
+ throwPairingPreflightError([
506
+ {
507
+ code: "hermes_api_server_unavailable",
508
+ zh: "Hermes API Server \u5F53\u524D\u4E0D\u53EF\u7528\uFF0CLink \u4E0D\u80FD\u786E\u8BA4 App \u914D\u5BF9\u540E\u53EF\u4EE5\u53D1\u9001\u6D88\u606F\u3002",
509
+ en: "Hermes API Server is not available, so Link cannot confirm that the App will be able to send messages after pairing.",
510
+ actionZh: "\u8BF7\u5148\u8FD0\u884C `hermes gateway run --replace` \u6216 `hermeslink doctor`\uFF0C\u786E\u8BA4 /health \u53EF\u4EE5\u8BBF\u95EE\u540E\u518D\u91CD\u65B0\u6267\u884C `hermeslink pair`\u3002",
511
+ actionEn: "Run `hermes gateway run --replace` or `hermeslink doctor` first, then retry `hermeslink pair` after /health is reachable.",
512
+ detail: error instanceof Error ? error.message : String(error)
513
+ }
514
+ ]);
515
+ }
516
+ }
517
+ function throwPairingPreflightError(failures) {
518
+ throw new LinkHttpError(
519
+ 503,
520
+ failures[0]?.code ?? "pairing_preflight_failed",
521
+ formatPairingPreflightMessage(failures)
522
+ );
523
+ }
524
+ function formatPairingPreflightMessage(failures) {
525
+ const lines = [
526
+ "\u914D\u5BF9\u524D\u68C0\u67E5\u6CA1\u6709\u901A\u8FC7\uFF0C\u6682\u65F6\u4E0D\u4F1A\u5411 HermesPilot Server \u6216 Relay \u7533\u8BF7\u914D\u5BF9\u4E8C\u7EF4\u7801/\u914D\u5BF9\u7801\u3002",
527
+ "Pairing preflight failed. Link did not request a pairing QR code or pairing code from HermesPilot Server or Relay.",
528
+ ""
529
+ ];
530
+ failures.forEach((failure, index) => {
531
+ const prefix = failures.length > 1 ? `${index + 1}. ` : "";
532
+ lines.push(`${prefix}${failure.zh}`);
533
+ lines.push(` ${failure.en}`);
534
+ lines.push(` \u5904\u7406\u5EFA\u8BAE\uFF1A${failure.actionZh}`);
535
+ lines.push(` Suggested fix: ${failure.actionEn}`);
536
+ if (failure.detail) {
537
+ lines.push(` Detail: ${failure.detail}`);
538
+ }
539
+ });
540
+ return lines.join("\n");
541
+ }
542
+ async function isDirectory(filePath) {
543
+ return stat(filePath).then((value) => value.isDirectory()).catch(() => false);
544
+ }
545
+ async function isReadableFile(filePath) {
546
+ return access(filePath).then(() => stat(filePath)).then((value) => value.isFile()).catch(() => false);
547
+ }
548
+ function normalizeProfileName(profileName) {
549
+ const value = profileName?.trim() || "default";
550
+ if (!/^[a-zA-Z0-9._-]{1,64}$/u.test(value)) {
551
+ throw new LinkHttpError(
552
+ 400,
553
+ "invalid_profile_name",
554
+ "invalid profile name"
555
+ );
556
+ }
557
+ return value;
558
+ }
559
+
772
560
  // src/cli/index.ts
773
561
  var program = new Command();
774
562
  var helpLanguage = detectSystemLanguage();
@@ -841,16 +629,25 @@ program.command("daemon").option("--foreground", "run in foreground").descriptio
841
629
  await waitForShutdown(async () => {
842
630
  await service.close();
843
631
  });
632
+ process.exit(0);
633
+ });
634
+ program.command("daemon-supervisor", { hidden: true }).action(async () => {
635
+ process.exitCode = await runDaemonSupervisor();
844
636
  });
845
637
  program.command("pair").description(helpText("pair.description")).action(async () => {
846
638
  const paths = resolveRuntimePaths();
847
639
  const config = await loadConfig(paths);
848
640
  const language = resolveLanguage(config.language);
849
641
  const t = translate.bind(null, language);
642
+ console.log(t("pair.preflight"));
643
+ const preflight = await assertPairingPreflightReady({ paths });
644
+ console.log(t("pair.hermesHome", { path: preflight.hermesHome }));
645
+ console.log(t("pair.apiReady", { port: preflight.apiServer.port ?? "unknown" }));
850
646
  console.log(t("pair.preparing"));
851
647
  console.log(t("pair.server", { url: config.serverBaseUrl }));
852
648
  console.log(t("pair.relay", { url: config.relayBaseUrl }));
853
649
  await ensureIdentity(paths);
650
+ const hadActiveDevices = await hasActiveDevices(paths);
854
651
  const probeBeforePair = await probeLocalLinkService({ port: config.port });
855
652
  const prepared = await preparePairing(paths);
856
653
  await clearPairingClaim(prepared.sessionId, paths);
@@ -878,9 +675,23 @@ program.command("pair").description(helpText("pair.description")).action(async (
878
675
  await clearPairingClaim(prepared.sessionId, paths);
879
676
  console.log(t(reusedRunningService ? "pair.claimedRunning" : "pair.claimed"));
880
677
  try {
881
- const autostart2 = await enableAutostart();
882
- if (autostart2.supported && autostart2.enabled) {
883
- console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
678
+ if (hadActiveDevices) {
679
+ console.log(t("pair.autostartUnchanged"));
680
+ } else {
681
+ const currentAutostart = await getAutostartStatus();
682
+ if (currentAutostart.supported && currentAutostart.enabled) {
683
+ console.log(
684
+ t("autostart.alreadyEnabled", {
685
+ method: currentAutostart.method,
686
+ path: currentAutostart.filePath ?? ""
687
+ })
688
+ );
689
+ } else {
690
+ const autostart2 = await enableAutostart();
691
+ if (autostart2.supported && autostart2.enabled) {
692
+ console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
693
+ }
694
+ }
884
695
  }
885
696
  } catch (error) {
886
697
  const message = error instanceof Error ? error.message : String(error);