@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/README.md +1 -1
- package/dist/chunk-TMCXOV6J.js +17419 -0
- package/dist/chunk-TMCXOV6J.js.map +1 -0
- package/dist/cli/index.js +363 -28
- package/dist/cli/index.js.map +1 -1
- package/dist/http/app.d.ts +342 -0
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-L2NM2XMX.js +0 -3099
- package/dist/chunk-L2NM2XMX.js.map +0 -1
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
|
-
|
|
18
|
-
|
|
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,
|
|
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
|
-
|
|
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?.
|
|
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
|
|
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
|
-
|
|
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
|
|
374
|
+
const config = await loadConfig(paths);
|
|
375
|
+
let status = await getDaemonStatus(paths);
|
|
264
376
|
if (status.running) {
|
|
265
|
-
|
|
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"
|
|
390
|
+
const child = spawn(process.execPath, [scriptPath, "daemon-supervisor"], {
|
|
272
391
|
detached: true,
|
|
273
|
-
stdio:
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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);
|