@hermespilot/link 0.1.8 → 0.2.0

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
@@ -1,21 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ ConversationService,
3
4
  LINK_COMMAND,
4
5
  LINK_VERSION,
6
+ LinkHttpError,
5
7
  clearPairingClaim,
6
8
  createApp,
7
9
  createFileLogger,
10
+ createRotatingTextLogWriter,
8
11
  ensureHermesApiServerAvailable,
9
12
  ensureHermesApiServerConfig,
10
13
  ensureIdentity,
14
+ getDaemonLogFile,
11
15
  getIdentityStatus,
12
16
  getLinkLogFile,
17
+ hasActiveDevices,
13
18
  loadConfig,
14
19
  loadIdentity,
20
+ migrateLinkDatabase,
15
21
  preparePairing,
22
+ readHermesApiServerConfig,
16
23
  readPairingClaim,
17
- resolveRuntimePaths
18
- } from "../chunk-L2NM2XMX.js";
24
+ resolveHermesConfigPath,
25
+ resolveHermesProfileDir,
26
+ resolveRuntimePaths,
27
+ syncHermesLinkCronDeliveries
28
+ } from "../chunk-TMCXOV6J.js";
19
29
 
20
30
  // src/cli/index.ts
21
31
  import { Command } from "commander";
@@ -30,7 +40,7 @@ import { promisify } from "util";
30
40
 
31
41
  // src/daemon/process.ts
32
42
  import { spawn } from "child_process";
33
- import { mkdir as mkdir2, open, readFile, rm as rm2 } from "fs/promises";
43
+ import { mkdir as mkdir2, readFile, rm as rm2 } from "fs/promises";
34
44
  import path from "path";
35
45
 
36
46
  // src/daemon/service.ts
@@ -51,8 +61,10 @@ function connectRelayControl(options) {
51
61
  let socket = null;
52
62
  let retryTimer = null;
53
63
  let abortControllers = /* @__PURE__ */ new Map();
64
+ let fatalRelayRejection = null;
54
65
  const connect = () => {
55
66
  options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
67
+ fatalRelayRejection = null;
56
68
  socket = new WebSocket(wsUrl, {
57
69
  headers: {
58
70
  "x-hermes-link-version": LINK_VERSION
@@ -73,11 +85,24 @@ function connectRelayControl(options) {
73
85
  });
74
86
  socket.on("error", (error) => {
75
87
  const message = error instanceof Error ? error.message : "Relay websocket error";
76
- options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts, message });
88
+ fatalRelayRejection = resolveFatalRelayRejection(message);
89
+ options.onStatus?.({
90
+ state: "disconnected",
91
+ attempt: reconnectAttempts,
92
+ message: fatalRelayRejection ?? message
93
+ });
77
94
  });
78
95
  socket.on("close", () => {
79
96
  abortAll(abortControllers);
80
97
  abortControllers = /* @__PURE__ */ new Map();
98
+ if (fatalRelayRejection) {
99
+ options.onStatus?.({
100
+ state: "failed",
101
+ attempt: reconnectAttempts,
102
+ message: fatalRelayRejection
103
+ });
104
+ return;
105
+ }
81
106
  if (closedByUser) {
82
107
  options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
83
108
  return;
@@ -99,12 +124,19 @@ function connectRelayControl(options) {
99
124
  closedByUser = true;
100
125
  if (retryTimer) {
101
126
  clearTimeout(retryTimer);
127
+ retryTimer = null;
102
128
  }
103
129
  abortAll(abortControllers);
104
- socket?.close();
130
+ socket?.terminate();
105
131
  }
106
132
  };
107
133
  }
134
+ function resolveFatalRelayRejection(message) {
135
+ if (!/Unexpected server response:\s*(400|401|403|426)\b/u.test(message)) {
136
+ return null;
137
+ }
138
+ return "Relay refused the Hermes Link connection. Check Link version and pairing state before retrying.";
139
+ }
108
140
  function abortAll(abortControllers) {
109
141
  for (const controller of abortControllers.values()) {
110
142
  controller.abort();
@@ -160,6 +192,39 @@ async function handleFrame(socket, raw, localPort, abortControllers) {
160
192
  }
161
193
  }
162
194
 
195
+ // src/daemon/scheduler.ts
196
+ function startCronDeliveryScheduler(options) {
197
+ let running = false;
198
+ const syncCronDeliveries = async () => {
199
+ if (running) {
200
+ return;
201
+ }
202
+ running = true;
203
+ try {
204
+ await syncHermesLinkCronDeliveries(
205
+ options.paths,
206
+ options.conversations,
207
+ options.logger
208
+ );
209
+ } catch (error) {
210
+ void options.logger.warn("cron_link_delivery_sync_failed", {
211
+ error: error instanceof Error ? error.message : String(error)
212
+ });
213
+ } finally {
214
+ running = false;
215
+ }
216
+ };
217
+ const timer = setInterval(() => {
218
+ void syncCronDeliveries();
219
+ }, options.intervalMs ?? 3e4);
220
+ timer.unref?.();
221
+ return {
222
+ close() {
223
+ clearInterval(timer);
224
+ }
225
+ };
226
+ }
227
+
163
228
  // src/daemon/service.ts
164
229
  async function startLinkService(options = {}) {
165
230
  const paths = options.paths ?? resolveRuntimePaths();
@@ -169,7 +234,22 @@ async function startLinkService(options = {}) {
169
234
  port: config.port,
170
235
  mode: identity?.link_id ? "paired" : "local-only"
171
236
  });
172
- const app = await createApp({ paths, logger, onPairingClaimed: options.onPairingClaimed });
237
+ const migration = await migrateLinkDatabase(paths);
238
+ if (migration.appliedVersions.length > 0) {
239
+ await logger.info("database_migrated", {
240
+ database_file: migration.databaseFile,
241
+ applied_versions: migration.appliedVersions,
242
+ current_version: migration.currentVersion
243
+ });
244
+ }
245
+ const conversations = new ConversationService(paths, logger);
246
+ await conversations.rebuildStatisticsIndex();
247
+ const app = await createApp({
248
+ paths,
249
+ logger,
250
+ conversations,
251
+ onPairingClaimed: options.onPairingClaimed
252
+ });
173
253
  const server = createServer(app.callback());
174
254
  try {
175
255
  await listenServer(server, config.port);
@@ -188,6 +268,11 @@ async function startLinkService(options = {}) {
188
268
  port: config.port,
189
269
  link_id: identity?.link_id ?? null
190
270
  });
271
+ const scheduler = startCronDeliveryScheduler({
272
+ paths,
273
+ conversations,
274
+ logger
275
+ });
191
276
  let relay = null;
192
277
  if (identity?.link_id) {
193
278
  relay = connectRelayControl({
@@ -209,6 +294,7 @@ async function startLinkService(options = {}) {
209
294
  }
210
295
  return {
211
296
  async close() {
297
+ scheduler.close();
212
298
  relay?.close();
213
299
  await closeServer(server);
214
300
  await logger.info("service_stopped");
@@ -229,13 +315,38 @@ async function writePidFile(paths) {
229
315
  }
230
316
  async function closeServer(server) {
231
317
  await new Promise((resolve, reject) => {
232
- server.close((error) => {
318
+ let settled = false;
319
+ let forceCloseTimer;
320
+ let timeoutTimer;
321
+ const settle = (error) => {
322
+ if (settled) {
323
+ return;
324
+ }
325
+ settled = true;
326
+ clearTimeout(forceCloseTimer);
327
+ clearTimeout(timeoutTimer);
233
328
  if (error) {
234
329
  reject(error);
235
330
  return;
236
331
  }
237
332
  resolve();
333
+ };
334
+ forceCloseTimer = setTimeout(() => {
335
+ server.closeIdleConnections?.();
336
+ server.closeAllConnections?.();
337
+ }, 250);
338
+ timeoutTimer = setTimeout(() => {
339
+ server.closeAllConnections?.();
340
+ settle();
341
+ }, 5e3);
342
+ server.close((error) => {
343
+ if (error) {
344
+ settle(error);
345
+ return;
346
+ }
347
+ settle();
238
348
  });
349
+ server.closeIdleConnections?.();
239
350
  });
240
351
  }
241
352
  async function listenServer(server, port) {
@@ -260,30 +371,79 @@ async function listenServer(server, port) {
260
371
 
261
372
  // src/daemon/process.ts
262
373
  async function startDaemonProcess(paths = resolveRuntimePaths()) {
263
- const status = await getDaemonStatus(paths);
374
+ const config = await loadConfig(paths);
375
+ let status = await getDaemonStatus(paths);
264
376
  if (status.running) {
265
- return status;
377
+ const probe = await probeLocalLinkService({ port: config.port, timeoutMs: 500 });
378
+ if (probe.reachable) {
379
+ return status;
380
+ }
381
+ await stopDaemonProcess(paths);
382
+ status = await getDaemonStatus(paths);
383
+ if (status.running) {
384
+ return status;
385
+ }
266
386
  }
267
387
  await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
268
388
  await mkdir2(paths.runDir, { recursive: true, mode: 448 });
269
- const log = await open(daemonLogFile(paths), "a", 384);
270
389
  const scriptPath = currentCliScriptPath();
271
- const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
390
+ const child = spawn(process.execPath, [scriptPath, "daemon-supervisor"], {
272
391
  detached: true,
273
- stdio: ["ignore", log.fd, log.fd],
392
+ stdio: "ignore",
274
393
  env: process.env
275
394
  });
276
395
  child.unref();
277
- await log.close();
278
396
  for (let index = 0; index < 12; index += 1) {
279
397
  await wait(250);
280
398
  const next = await getDaemonStatus(paths);
281
- if (next.running) {
399
+ if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
282
400
  return next;
283
401
  }
284
402
  }
285
403
  return await getDaemonStatus(paths);
286
404
  }
405
+ async function runDaemonSupervisor(paths = resolveRuntimePaths()) {
406
+ await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
407
+ const log = createRotatingTextLogWriter({
408
+ paths,
409
+ fileName: path.basename(daemonLogFile(paths))
410
+ });
411
+ const scriptPath = currentCliScriptPath();
412
+ const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
413
+ stdio: ["ignore", "pipe", "pipe"],
414
+ env: process.env
415
+ });
416
+ const write = (chunk) => {
417
+ void log.write(chunk);
418
+ };
419
+ write(`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor started
420
+ `);
421
+ child.stdout?.on("data", write);
422
+ child.stderr?.on("data", write);
423
+ const forwardStop = () => {
424
+ if (child.pid && isProcessAlive(child.pid)) {
425
+ child.kill("SIGTERM");
426
+ }
427
+ };
428
+ process.once("SIGINT", forwardStop);
429
+ process.once("SIGTERM", forwardStop);
430
+ const result = await new Promise((resolve, reject) => {
431
+ child.once("error", reject);
432
+ child.once("exit", (code, signal) => resolve({ code, signal }));
433
+ }).catch((error) => {
434
+ write(`[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor failed: ${error instanceof Error ? error.message : String(error)}
435
+ `);
436
+ return { code: 1, signal: null };
437
+ });
438
+ process.off("SIGINT", forwardStop);
439
+ process.off("SIGTERM", forwardStop);
440
+ write(
441
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] daemon supervisor stopped code=${result.code ?? "null"} signal=${result.signal ?? "null"}
442
+ `
443
+ );
444
+ await log.flush();
445
+ return result.code ?? (result.signal ? 0 : 1);
446
+ }
287
447
  async function probeLocalLinkService(options) {
288
448
  const unreachable = {
289
449
  reachable: false,
@@ -332,7 +492,19 @@ async function stopDaemonProcess(paths = resolveRuntimePaths()) {
332
492
  break;
333
493
  }
334
494
  }
335
- if (!isProcessAlive(status.pid)) {
495
+ if (isProcessAlive(status.pid)) {
496
+ try {
497
+ process.kill(status.pid, "SIGKILL");
498
+ } catch {
499
+ }
500
+ for (let index = 0; index < 10; index += 1) {
501
+ await wait(250);
502
+ if (!isProcessAlive(status.pid)) {
503
+ break;
504
+ }
505
+ }
506
+ }
507
+ if (!isProcessAlive(status.pid) || !await pidBackedServiceIsReachable(paths)) {
336
508
  await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
337
509
  }
338
510
  return await getDaemonStatus(paths);
@@ -357,7 +529,7 @@ async function getDaemonStatus(paths = resolveRuntimePaths()) {
357
529
  };
358
530
  }
359
531
  function daemonLogFile(paths = resolveRuntimePaths()) {
360
- return path.join(paths.logsDir, "daemon.log");
532
+ return getDaemonLogFile(paths);
361
533
  }
362
534
  function currentCliScriptPath() {
363
535
  return process.argv[1];
@@ -378,6 +550,13 @@ function isProcessAlive(pid) {
378
550
  return false;
379
551
  }
380
552
  }
553
+ async function pidBackedServiceIsReachable(paths) {
554
+ const config = await loadConfig(paths).catch(() => null);
555
+ if (!config) {
556
+ return false;
557
+ }
558
+ return (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable;
559
+ }
381
560
  function wait(ms) {
382
561
  return new Promise((resolve) => setTimeout(resolve, ms));
383
562
  }
@@ -483,17 +662,12 @@ function launchdDefinition() {
483
662
  <array>
484
663
  <string>${xmlEscape(process.execPath)}</string>
485
664
  <string>${xmlEscape(currentCliScriptPath())}</string>
486
- <string>daemon</string>
487
- <string>--foreground</string>
665
+ <string>daemon-supervisor</string>
488
666
  </array>
489
667
  <key>RunAtLoad</key>
490
668
  <true/>
491
669
  <key>KeepAlive</key>
492
670
  <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
671
  </dict>
498
672
  </plist>
499
673
  `
@@ -510,7 +684,7 @@ After=network-online.target
510
684
 
511
685
  [Service]
512
686
  Type=simple
513
- ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon --foreground
687
+ ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon-supervisor
514
688
  Restart=no
515
689
 
516
690
  [Install]
@@ -526,7 +700,7 @@ function xdgAutostartDefinition() {
526
700
  content: `[Desktop Entry]
527
701
  Type=Application
528
702
  Name=Hermes Link
529
- Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon --foreground
703
+ Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon-supervisor
530
704
  Terminal=false
531
705
  X-GNOME-Autostart-enabled=true
532
706
  `
@@ -539,7 +713,7 @@ function windowsStartupDefinition() {
539
713
  method: "windows-startup",
540
714
  filePath,
541
715
  content: `@echo off\r
542
- start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon --foreground\r
716
+ start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon-supervisor\r
543
717
  `
544
718
  };
545
719
  }
@@ -598,7 +772,11 @@ var messages = {
598
772
  "autostart.status.enabled": "Boot autostart: enabled via {method}: {path}",
599
773
  "autostart.status.disabled": "Boot autostart: disabled. Method: {method}. File: {path}",
600
774
  "autostart.unsupported": "Boot autostart is not supported on this platform yet.",
775
+ "autostart.alreadyEnabled": "Boot autostart is already enabled via {method}: {path}",
601
776
  "pair.description": "Create a Hermes Link pairing session",
777
+ "pair.preflight": "Checking local Hermes configuration before pairing...",
778
+ "pair.hermesHome": "Hermes home: {path}",
779
+ "pair.apiReady": "Hermes API Server is ready on 127.0.0.1:{port}",
602
780
  "pair.preparing": "Preparing pairing session through HermesPilot Server and Relay...",
603
781
  "pair.server": "Server: {url}",
604
782
  "pair.relay": "Relay: {url}",
@@ -609,6 +787,7 @@ var messages = {
609
787
  "pair.expires": "Pairing expires in 10 minutes. Press Ctrl+C to cancel waiting.",
610
788
  "pair.claimed": "Pairing succeeded. Starting Hermes Link in the background...",
611
789
  "pair.claimedRunning": "Pairing succeeded. Hermes Link is already running in the background.",
790
+ "pair.autostartUnchanged": "Existing paired devices found. Boot autostart settings were left unchanged.",
612
791
  "pair.autostartFailed": "Pairing succeeded, but boot autostart could not be enabled: {message}",
613
792
  "doctor.description": "Run local diagnostics",
614
793
  "doctor.identityOk": "Runtime identity: OK",
@@ -662,7 +841,11 @@ var messages = {
662
841
  "autostart.status.enabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
663
842
  "autostart.status.disabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u672A\u542F\u7528\u3002\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
664
843
  "autostart.unsupported": "\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u5F00\u673A\u81EA\u542F\u3002",
844
+ "autostart.alreadyEnabled": "\u5F00\u673A\u81EA\u542F\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
665
845
  "pair.description": "\u521B\u5EFA Hermes Link \u914D\u5BF9\u4F1A\u8BDD",
846
+ "pair.preflight": "\u6B63\u5728\u914D\u5BF9\u524D\u68C0\u67E5\u672C\u673A Hermes \u914D\u7F6E...",
847
+ "pair.hermesHome": "Hermes \u6570\u636E\u76EE\u5F55\uFF1A{path}",
848
+ "pair.apiReady": "Hermes API Server \u5DF2\u5C31\u7EEA\uFF1A127.0.0.1:{port}",
666
849
  "pair.preparing": "\u6B63\u5728\u901A\u8FC7 HermesPilot Server \u548C Relay \u521B\u5EFA\u914D\u5BF9\u4F1A\u8BDD...",
667
850
  "pair.server": "Server\uFF1A{url}",
668
851
  "pair.relay": "Relay\uFF1A{url}",
@@ -673,6 +856,7 @@ var messages = {
673
856
  "pair.expires": "\u914D\u5BF9\u4F1A\u8BDD 10 \u5206\u949F\u540E\u8FC7\u671F\u3002\u6309 Ctrl+C \u9000\u51FA\u7B49\u5F85\u3002",
674
857
  "pair.claimed": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002\u6B63\u5728\u628A Hermes Link \u5207\u6362\u5230\u540E\u53F0\u8FD0\u884C...",
675
858
  "pair.claimedRunning": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002Hermes Link \u5DF2\u5728\u540E\u53F0\u6301\u7EED\u8FD0\u884C\u3002",
859
+ "pair.autostartUnchanged": "\u68C0\u6D4B\u5230\u5DF2\u6709\u914D\u5BF9\u8BBE\u5907\uFF0C\u5F00\u673A\u81EA\u542F\u8BBE\u7F6E\u4FDD\u6301\u4E0D\u53D8\u3002",
676
860
  "pair.autostartFailed": "\u914D\u5BF9\u5DF2\u6210\u529F\uFF0C\u4F46\u542F\u7528\u5F00\u673A\u81EA\u542F\u5931\u8D25\uFF1A{message}",
677
861
  "doctor.description": "\u8FD0\u884C\u672C\u673A\u8BCA\u65AD",
678
862
  "doctor.identityOk": "\u8FD0\u884C\u8EAB\u4EFD\uFF1A\u6B63\u5E38",
@@ -769,6 +953,134 @@ function parseLanguage(value) {
769
953
  return null;
770
954
  }
771
955
 
956
+ // src/pairing/preflight.ts
957
+ import { access, stat } from "fs/promises";
958
+ import path3 from "path";
959
+ async function assertPairingPreflightReady(options = {}) {
960
+ const profileName = normalizeProfileName(options.profileName);
961
+ const hermesHome = resolveHermesProfileDir(profileName);
962
+ const configPath = resolveHermesConfigPath(profileName);
963
+ const envPath = path3.join(hermesHome, ".env");
964
+ const failures = [];
965
+ if (!await isDirectory(hermesHome)) {
966
+ failures.push({
967
+ code: "hermes_home_missing",
968
+ zh: `\u6CA1\u6709\u627E\u5230\u5F53\u524D Hermes \u6570\u636E\u76EE\u5F55\uFF1A${hermesHome}`,
969
+ en: `Current Hermes home was not found: ${hermesHome}`,
970
+ 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",
971
+ 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."
972
+ });
973
+ }
974
+ if (!await isReadableFile(configPath)) {
975
+ failures.push({
976
+ code: "hermes_config_missing",
977
+ zh: `\u6CA1\u6709\u627E\u5230 Hermes \u914D\u7F6E\u6587\u4EF6\uFF1A${configPath}`,
978
+ en: `Hermes config file was not found: ${configPath}`,
979
+ 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",
980
+ actionEn: "Run `hermes setup` to create the config, or make sure Link and Hermes use the same HERMES_HOME."
981
+ });
982
+ }
983
+ if (!await isReadableFile(envPath)) {
984
+ failures.push({
985
+ code: "hermes_env_missing",
986
+ zh: `\u6CA1\u6709\u627E\u5230 Hermes \u73AF\u5883\u914D\u7F6E\u6587\u4EF6\uFF1A${envPath}`,
987
+ en: `Hermes environment file was not found: ${envPath}`,
988
+ 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",
989
+ actionEn: "Run `hermes setup` to create `.env` and configure model/API keys. Link must be able to read it to reuse Hermes settings."
990
+ });
991
+ }
992
+ if (failures.length > 0) {
993
+ throwPairingPreflightError(failures);
994
+ }
995
+ const apiServerConfig = await readHermesApiServerConfig(profileName, configPath);
996
+ if (apiServerConfig.enabled !== true) {
997
+ throwPairingPreflightError([
998
+ {
999
+ code: "hermes_api_server_disabled",
1000
+ zh: "Hermes API Server \u8FD8\u6CA1\u6709\u5F00\u542F\u3002",
1001
+ en: "Hermes API Server is not enabled.",
1002
+ 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",
1003
+ actionEn: "Run `hermeslink doctor` so Link can prepare the API Server config, or enable platforms.api_server in Hermes config."
1004
+ }
1005
+ ]);
1006
+ }
1007
+ try {
1008
+ const ensureAvailable = options.ensureApiServerAvailable ?? ensureHermesApiServerAvailable;
1009
+ const availability = await ensureAvailable({
1010
+ paths: options.paths,
1011
+ profileName,
1012
+ fetchImpl: options.fetchImpl,
1013
+ timeoutMs: 5e3,
1014
+ autoStart: true
1015
+ });
1016
+ return {
1017
+ profileName,
1018
+ hermesHome,
1019
+ configPath,
1020
+ envPath,
1021
+ apiServer: {
1022
+ available: true,
1023
+ started: availability.started,
1024
+ host: availability.configResult.apiServer.host ?? null,
1025
+ port: availability.configResult.apiServer.port ?? null
1026
+ }
1027
+ };
1028
+ } catch (error) {
1029
+ throwPairingPreflightError([
1030
+ {
1031
+ code: "hermes_api_server_unavailable",
1032
+ 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",
1033
+ en: "Hermes API Server is not available, so Link cannot confirm that the App will be able to send messages after pairing.",
1034
+ 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",
1035
+ actionEn: "Run `hermes gateway run --replace` or `hermeslink doctor` first, then retry `hermeslink pair` after /health is reachable.",
1036
+ detail: error instanceof Error ? error.message : String(error)
1037
+ }
1038
+ ]);
1039
+ }
1040
+ }
1041
+ function throwPairingPreflightError(failures) {
1042
+ throw new LinkHttpError(
1043
+ 503,
1044
+ failures[0]?.code ?? "pairing_preflight_failed",
1045
+ formatPairingPreflightMessage(failures)
1046
+ );
1047
+ }
1048
+ function formatPairingPreflightMessage(failures) {
1049
+ const lines = [
1050
+ "\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",
1051
+ "Pairing preflight failed. Link did not request a pairing QR code or pairing code from HermesPilot Server or Relay.",
1052
+ ""
1053
+ ];
1054
+ failures.forEach((failure, index) => {
1055
+ const prefix = failures.length > 1 ? `${index + 1}. ` : "";
1056
+ lines.push(`${prefix}${failure.zh}`);
1057
+ lines.push(` ${failure.en}`);
1058
+ lines.push(` \u5904\u7406\u5EFA\u8BAE\uFF1A${failure.actionZh}`);
1059
+ lines.push(` Suggested fix: ${failure.actionEn}`);
1060
+ if (failure.detail) {
1061
+ lines.push(` Detail: ${failure.detail}`);
1062
+ }
1063
+ });
1064
+ return lines.join("\n");
1065
+ }
1066
+ async function isDirectory(filePath) {
1067
+ return stat(filePath).then((value) => value.isDirectory()).catch(() => false);
1068
+ }
1069
+ async function isReadableFile(filePath) {
1070
+ return access(filePath).then(() => stat(filePath)).then((value) => value.isFile()).catch(() => false);
1071
+ }
1072
+ function normalizeProfileName(profileName) {
1073
+ const value = profileName?.trim() || "default";
1074
+ if (!/^[a-zA-Z0-9._-]{1,64}$/u.test(value)) {
1075
+ throw new LinkHttpError(
1076
+ 400,
1077
+ "invalid_profile_name",
1078
+ "invalid profile name"
1079
+ );
1080
+ }
1081
+ return value;
1082
+ }
1083
+
772
1084
  // src/cli/index.ts
773
1085
  var program = new Command();
774
1086
  var helpLanguage = detectSystemLanguage();
@@ -841,16 +1153,25 @@ program.command("daemon").option("--foreground", "run in foreground").descriptio
841
1153
  await waitForShutdown(async () => {
842
1154
  await service.close();
843
1155
  });
1156
+ process.exit(0);
1157
+ });
1158
+ program.command("daemon-supervisor", { hidden: true }).action(async () => {
1159
+ process.exitCode = await runDaemonSupervisor();
844
1160
  });
845
1161
  program.command("pair").description(helpText("pair.description")).action(async () => {
846
1162
  const paths = resolveRuntimePaths();
847
1163
  const config = await loadConfig(paths);
848
1164
  const language = resolveLanguage(config.language);
849
1165
  const t = translate.bind(null, language);
1166
+ console.log(t("pair.preflight"));
1167
+ const preflight = await assertPairingPreflightReady({ paths });
1168
+ console.log(t("pair.hermesHome", { path: preflight.hermesHome }));
1169
+ console.log(t("pair.apiReady", { port: preflight.apiServer.port ?? "unknown" }));
850
1170
  console.log(t("pair.preparing"));
851
1171
  console.log(t("pair.server", { url: config.serverBaseUrl }));
852
1172
  console.log(t("pair.relay", { url: config.relayBaseUrl }));
853
1173
  await ensureIdentity(paths);
1174
+ const hadActiveDevices = await hasActiveDevices(paths);
854
1175
  const probeBeforePair = await probeLocalLinkService({ port: config.port });
855
1176
  const prepared = await preparePairing(paths);
856
1177
  await clearPairingClaim(prepared.sessionId, paths);
@@ -878,9 +1199,23 @@ program.command("pair").description(helpText("pair.description")).action(async (
878
1199
  await clearPairingClaim(prepared.sessionId, paths);
879
1200
  console.log(t(reusedRunningService ? "pair.claimedRunning" : "pair.claimed"));
880
1201
  try {
881
- const autostart2 = await enableAutostart();
882
- if (autostart2.supported && autostart2.enabled) {
883
- console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
1202
+ if (hadActiveDevices) {
1203
+ console.log(t("pair.autostartUnchanged"));
1204
+ } else {
1205
+ const currentAutostart = await getAutostartStatus();
1206
+ if (currentAutostart.supported && currentAutostart.enabled) {
1207
+ console.log(
1208
+ t("autostart.alreadyEnabled", {
1209
+ method: currentAutostart.method,
1210
+ path: currentAutostart.filePath ?? ""
1211
+ })
1212
+ );
1213
+ } else {
1214
+ const autostart2 = await enableAutostart();
1215
+ if (autostart2.supported && autostart2.enabled) {
1216
+ console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
1217
+ }
1218
+ }
884
1219
  }
885
1220
  } catch (error) {
886
1221
  const message = error instanceof Error ? error.message : String(error);