@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/README.md +1 -1
- package/dist/chunk-YQX7OQFH.js +17967 -0
- package/dist/chunk-YQX7OQFH.js.map +1 -0
- package/dist/cli/index.js +199 -388
- 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-QAPDGN52.js +0 -3099
- package/dist/chunk-QAPDGN52.js.map +0 -1
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
394
|
-
await
|
|
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",
|
|
397
|
-
await
|
|
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
|
|
400
|
-
await
|
|
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",
|
|
64
|
+
await execFileAsync("systemctl", ["--user", "disable", path.basename(definition.filePath)]).catch(() => void 0);
|
|
410
65
|
}
|
|
411
|
-
await
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 ??
|
|
537
|
-
const filePath =
|
|
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
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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);
|