@hermespilot/link 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
ConversationService,
|
|
4
3
|
LINK_COMMAND,
|
|
5
4
|
LINK_VERSION,
|
|
6
5
|
LinkHttpError,
|
|
7
6
|
clearPairingClaim,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
createRotatingTextLogWriter,
|
|
7
|
+
currentCliScriptPath,
|
|
8
|
+
daemonLogFile,
|
|
11
9
|
ensureHermesApiServerAvailable,
|
|
12
10
|
ensureHermesApiServerConfig,
|
|
13
11
|
ensureIdentity,
|
|
14
|
-
|
|
12
|
+
getDaemonStatus,
|
|
15
13
|
getIdentityStatus,
|
|
16
14
|
getLinkLogFile,
|
|
17
15
|
hasActiveDevices,
|
|
18
16
|
loadConfig,
|
|
19
17
|
loadIdentity,
|
|
20
|
-
migrateLinkDatabase,
|
|
21
18
|
preparePairing,
|
|
19
|
+
probeLocalLinkService,
|
|
22
20
|
readHermesApiServerConfig,
|
|
23
21
|
readPairingClaim,
|
|
24
22
|
resolveHermesConfigPath,
|
|
25
23
|
resolveHermesProfileDir,
|
|
26
24
|
resolveRuntimePaths,
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
runDaemonSupervisor,
|
|
26
|
+
startDaemonProcess,
|
|
27
|
+
startLinkService,
|
|
28
|
+
stopDaemonProcess
|
|
29
|
+
} from "../chunk-YQX7OQFH.js";
|
|
29
30
|
|
|
30
31
|
// src/cli/index.ts
|
|
31
32
|
import { Command } from "commander";
|
|
@@ -33,535 +34,10 @@ import qrcode from "qrcode-terminal";
|
|
|
33
34
|
|
|
34
35
|
// src/autostart/autostart.ts
|
|
35
36
|
import { execFile } from "child_process";
|
|
36
|
-
import { mkdir
|
|
37
|
+
import { mkdir, readFile, rm, writeFile } from "fs/promises";
|
|
37
38
|
import os from "os";
|
|
38
|
-
import path2 from "path";
|
|
39
|
-
import { promisify } from "util";
|
|
40
|
-
|
|
41
|
-
// src/daemon/process.ts
|
|
42
|
-
import { spawn } from "child_process";
|
|
43
|
-
import { mkdir as mkdir2, readFile, rm as rm2 } from "fs/promises";
|
|
44
39
|
import path from "path";
|
|
45
|
-
|
|
46
|
-
// src/daemon/service.ts
|
|
47
|
-
import { createServer } from "http";
|
|
48
|
-
import { mkdir, rm, writeFile } from "fs/promises";
|
|
49
|
-
|
|
50
|
-
// src/relay/control-client.ts
|
|
51
|
-
import WebSocket from "ws";
|
|
52
|
-
function connectRelayControl(options) {
|
|
53
|
-
const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
|
|
54
|
-
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
55
|
-
wsUrl.searchParams.set("link_id", options.linkId);
|
|
56
|
-
const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
57
|
-
const backoffBaseMs = options.backoffBaseMs ?? 1e3;
|
|
58
|
-
const backoffMaxMs = options.backoffMaxMs ?? 3e4;
|
|
59
|
-
let reconnectAttempts = 0;
|
|
60
|
-
let closedByUser = false;
|
|
61
|
-
let socket = null;
|
|
62
|
-
let retryTimer = null;
|
|
63
|
-
let abortControllers = /* @__PURE__ */ new Map();
|
|
64
|
-
let fatalRelayRejection = null;
|
|
65
|
-
const connect = () => {
|
|
66
|
-
options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
|
|
67
|
-
fatalRelayRejection = null;
|
|
68
|
-
socket = new WebSocket(wsUrl, {
|
|
69
|
-
headers: {
|
|
70
|
-
"x-hermes-link-version": LINK_VERSION
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
socket.on("open", () => {
|
|
74
|
-
reconnectAttempts = 0;
|
|
75
|
-
options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
|
|
76
|
-
});
|
|
77
|
-
socket.on("message", (raw) => {
|
|
78
|
-
if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
|
|
82
|
-
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
83
|
-
socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
socket.on("error", (error) => {
|
|
87
|
-
const message = error instanceof Error ? error.message : "Relay websocket error";
|
|
88
|
-
fatalRelayRejection = resolveFatalRelayRejection(message);
|
|
89
|
-
options.onStatus?.({
|
|
90
|
-
state: "disconnected",
|
|
91
|
-
attempt: reconnectAttempts,
|
|
92
|
-
message: fatalRelayRejection ?? message
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
socket.on("close", () => {
|
|
96
|
-
abortAll(abortControllers);
|
|
97
|
-
abortControllers = /* @__PURE__ */ new Map();
|
|
98
|
-
if (fatalRelayRejection) {
|
|
99
|
-
options.onStatus?.({
|
|
100
|
-
state: "failed",
|
|
101
|
-
attempt: reconnectAttempts,
|
|
102
|
-
message: fatalRelayRejection
|
|
103
|
-
});
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (closedByUser) {
|
|
107
|
-
options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
111
|
-
options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
reconnectAttempts += 1;
|
|
115
|
-
const delay = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
|
|
116
|
-
options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay}ms` });
|
|
117
|
-
retryTimer = setTimeout(connect, delay);
|
|
118
|
-
retryTimer.unref?.();
|
|
119
|
-
});
|
|
120
|
-
};
|
|
121
|
-
connect();
|
|
122
|
-
return {
|
|
123
|
-
close() {
|
|
124
|
-
closedByUser = true;
|
|
125
|
-
if (retryTimer) {
|
|
126
|
-
clearTimeout(retryTimer);
|
|
127
|
-
retryTimer = null;
|
|
128
|
-
}
|
|
129
|
-
abortAll(abortControllers);
|
|
130
|
-
socket?.terminate();
|
|
131
|
-
}
|
|
132
|
-
};
|
|
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
|
-
}
|
|
140
|
-
function abortAll(abortControllers) {
|
|
141
|
-
for (const controller of abortControllers.values()) {
|
|
142
|
-
controller.abort();
|
|
143
|
-
}
|
|
144
|
-
abortControllers.clear();
|
|
145
|
-
}
|
|
146
|
-
function computeBackoffMs(attempt, baseMs, maxMs) {
|
|
147
|
-
const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
|
|
148
|
-
const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
|
|
149
|
-
return exponential + jitter;
|
|
150
|
-
}
|
|
151
|
-
async function handleFrame(socket, raw, localPort, abortControllers) {
|
|
152
|
-
const frame = JSON.parse(raw);
|
|
153
|
-
if (frame.type === "http.cancel") {
|
|
154
|
-
abortControllers.get(frame.id)?.abort();
|
|
155
|
-
abortControllers.delete(frame.id);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
if (frame.type !== "http.request") {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
const abortController = new AbortController();
|
|
162
|
-
abortControllers.set(frame.id, abortController);
|
|
163
|
-
try {
|
|
164
|
-
const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
|
|
165
|
-
method: frame.method,
|
|
166
|
-
headers: frame.headers ?? {},
|
|
167
|
-
body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
|
|
168
|
-
signal: abortController.signal
|
|
169
|
-
});
|
|
170
|
-
const headers = Object.fromEntries(response.headers.entries());
|
|
171
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
172
|
-
if (response.body && contentType.includes("text/event-stream")) {
|
|
173
|
-
socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
|
|
174
|
-
const reader = response.body.getReader();
|
|
175
|
-
while (true) {
|
|
176
|
-
const next = await reader.read();
|
|
177
|
-
if (next.done) {
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
|
|
181
|
-
}
|
|
182
|
-
socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const body = Buffer.from(await response.arrayBuffer()).toString("base64");
|
|
186
|
-
socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
|
|
187
|
-
} catch (error) {
|
|
188
|
-
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
189
|
-
socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
|
|
190
|
-
} finally {
|
|
191
|
-
abortControllers.delete(frame.id);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
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
|
-
|
|
228
|
-
// src/daemon/service.ts
|
|
229
|
-
async function startLinkService(options = {}) {
|
|
230
|
-
const paths = options.paths ?? resolveRuntimePaths();
|
|
231
|
-
const logger = createFileLogger({ paths });
|
|
232
|
-
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
233
|
-
await logger.info("service_starting", {
|
|
234
|
-
port: config.port,
|
|
235
|
-
mode: identity?.link_id ? "paired" : "local-only"
|
|
236
|
-
});
|
|
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
|
-
});
|
|
253
|
-
const server = createServer(app.callback());
|
|
254
|
-
try {
|
|
255
|
-
await listenServer(server, config.port);
|
|
256
|
-
} catch (error) {
|
|
257
|
-
await logger.error("service_start_failed", {
|
|
258
|
-
port: config.port,
|
|
259
|
-
error: error instanceof Error ? error.message : String(error)
|
|
260
|
-
});
|
|
261
|
-
await logger.flush();
|
|
262
|
-
throw error;
|
|
263
|
-
}
|
|
264
|
-
server.on("error", (error) => {
|
|
265
|
-
void logger.error("service_error", { error: error.message });
|
|
266
|
-
});
|
|
267
|
-
void logger.info("service_started", {
|
|
268
|
-
port: config.port,
|
|
269
|
-
link_id: identity?.link_id ?? null
|
|
270
|
-
});
|
|
271
|
-
const scheduler = startCronDeliveryScheduler({
|
|
272
|
-
paths,
|
|
273
|
-
conversations,
|
|
274
|
-
logger
|
|
275
|
-
});
|
|
276
|
-
let relay = null;
|
|
277
|
-
if (identity?.link_id) {
|
|
278
|
-
relay = connectRelayControl({
|
|
279
|
-
relayBaseUrl: config.relayBaseUrl,
|
|
280
|
-
linkId: identity.link_id,
|
|
281
|
-
localPort: config.port,
|
|
282
|
-
maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
|
|
283
|
-
backoffBaseMs: 1e3,
|
|
284
|
-
backoffMaxMs: 3e4,
|
|
285
|
-
onStatus: (status) => {
|
|
286
|
-
void logger.info("relay_status", status);
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
} else {
|
|
290
|
-
void logger.info("relay_skipped", { reason: "link_not_paired" });
|
|
291
|
-
}
|
|
292
|
-
if (options.writePidFile) {
|
|
293
|
-
await writePidFile(paths);
|
|
294
|
-
}
|
|
295
|
-
return {
|
|
296
|
-
async close() {
|
|
297
|
-
scheduler.close();
|
|
298
|
-
relay?.close();
|
|
299
|
-
await closeServer(server);
|
|
300
|
-
await logger.info("service_stopped");
|
|
301
|
-
await logger.flush();
|
|
302
|
-
if (options.writePidFile) {
|
|
303
|
-
await rm(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
function pidFilePath(paths = resolveRuntimePaths()) {
|
|
309
|
-
return `${paths.runDir}/hermeslink.pid`;
|
|
310
|
-
}
|
|
311
|
-
async function writePidFile(paths) {
|
|
312
|
-
await mkdir(paths.runDir, { recursive: true, mode: 448 });
|
|
313
|
-
await writeFile(pidFilePath(paths), `${process.pid}
|
|
314
|
-
`, { mode: 384 });
|
|
315
|
-
}
|
|
316
|
-
async function closeServer(server) {
|
|
317
|
-
await new Promise((resolve, reject) => {
|
|
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);
|
|
328
|
-
if (error) {
|
|
329
|
-
reject(error);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
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();
|
|
348
|
-
});
|
|
349
|
-
server.closeIdleConnections?.();
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
async function listenServer(server, port) {
|
|
353
|
-
await new Promise((resolve, reject) => {
|
|
354
|
-
const cleanup = () => {
|
|
355
|
-
server.off("error", onError);
|
|
356
|
-
server.off("listening", onListening);
|
|
357
|
-
};
|
|
358
|
-
const onError = (error) => {
|
|
359
|
-
cleanup();
|
|
360
|
-
reject(error);
|
|
361
|
-
};
|
|
362
|
-
const onListening = () => {
|
|
363
|
-
cleanup();
|
|
364
|
-
resolve();
|
|
365
|
-
};
|
|
366
|
-
server.once("error", onError);
|
|
367
|
-
server.once("listening", onListening);
|
|
368
|
-
server.listen(port);
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// src/daemon/process.ts
|
|
373
|
-
async function startDaemonProcess(paths = resolveRuntimePaths()) {
|
|
374
|
-
const config = await loadConfig(paths);
|
|
375
|
-
let status = await getDaemonStatus(paths);
|
|
376
|
-
if (status.running) {
|
|
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
|
-
}
|
|
386
|
-
}
|
|
387
|
-
await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
|
|
388
|
-
await mkdir2(paths.runDir, { recursive: true, mode: 448 });
|
|
389
|
-
const scriptPath = currentCliScriptPath();
|
|
390
|
-
const child = spawn(process.execPath, [scriptPath, "daemon-supervisor"], {
|
|
391
|
-
detached: true,
|
|
392
|
-
stdio: "ignore",
|
|
393
|
-
env: process.env
|
|
394
|
-
});
|
|
395
|
-
child.unref();
|
|
396
|
-
for (let index = 0; index < 12; index += 1) {
|
|
397
|
-
await wait(250);
|
|
398
|
-
const next = await getDaemonStatus(paths);
|
|
399
|
-
if (next.running && (await probeLocalLinkService({ port: config.port, timeoutMs: 500 })).reachable) {
|
|
400
|
-
return next;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return await getDaemonStatus(paths);
|
|
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
|
-
}
|
|
447
|
-
async function probeLocalLinkService(options) {
|
|
448
|
-
const unreachable = {
|
|
449
|
-
reachable: false,
|
|
450
|
-
reusable: false,
|
|
451
|
-
linkId: null,
|
|
452
|
-
version: null
|
|
453
|
-
};
|
|
454
|
-
let response;
|
|
455
|
-
try {
|
|
456
|
-
response = await fetch(`http://127.0.0.1:${options.port}/api/v1/bootstrap`, {
|
|
457
|
-
headers: { accept: "application/json" },
|
|
458
|
-
signal: AbortSignal.timeout(options.timeoutMs ?? 1e3)
|
|
459
|
-
});
|
|
460
|
-
} catch {
|
|
461
|
-
return unreachable;
|
|
462
|
-
}
|
|
463
|
-
if (!response.ok) {
|
|
464
|
-
return unreachable;
|
|
465
|
-
}
|
|
466
|
-
const payload = await response.json().catch(() => null);
|
|
467
|
-
if (!payload || payload.api_version !== 1) {
|
|
468
|
-
return unreachable;
|
|
469
|
-
}
|
|
470
|
-
const linkId = typeof payload.link_id === "string" ? payload.link_id : null;
|
|
471
|
-
return {
|
|
472
|
-
reachable: true,
|
|
473
|
-
reusable: options.linkId ? linkId === options.linkId : true,
|
|
474
|
-
linkId,
|
|
475
|
-
version: typeof payload.version === "string" ? payload.version : null
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
async function stopDaemonProcess(paths = resolveRuntimePaths()) {
|
|
479
|
-
const status = await getDaemonStatus(paths);
|
|
480
|
-
if (!status.running || !status.pid) {
|
|
481
|
-
return status;
|
|
482
|
-
}
|
|
483
|
-
try {
|
|
484
|
-
process.kill(status.pid, "SIGTERM");
|
|
485
|
-
} catch {
|
|
486
|
-
await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
487
|
-
return await getDaemonStatus(paths);
|
|
488
|
-
}
|
|
489
|
-
for (let index = 0; index < 20; index += 1) {
|
|
490
|
-
await wait(250);
|
|
491
|
-
if (!isProcessAlive(status.pid)) {
|
|
492
|
-
break;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
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)) {
|
|
508
|
-
await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
509
|
-
}
|
|
510
|
-
return await getDaemonStatus(paths);
|
|
511
|
-
}
|
|
512
|
-
async function getDaemonStatus(paths = resolveRuntimePaths()) {
|
|
513
|
-
const pidFile = pidFilePath(paths);
|
|
514
|
-
const pid = await readPid(pidFile);
|
|
515
|
-
if (pid && !isProcessAlive(pid)) {
|
|
516
|
-
await rm2(pidFile, { force: true }).catch(() => void 0);
|
|
517
|
-
return {
|
|
518
|
-
running: false,
|
|
519
|
-
pid: null,
|
|
520
|
-
pidFile,
|
|
521
|
-
logFile: daemonLogFile(paths)
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
return {
|
|
525
|
-
running: Boolean(pid),
|
|
526
|
-
pid,
|
|
527
|
-
pidFile,
|
|
528
|
-
logFile: daemonLogFile(paths)
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
function daemonLogFile(paths = resolveRuntimePaths()) {
|
|
532
|
-
return getDaemonLogFile(paths);
|
|
533
|
-
}
|
|
534
|
-
function currentCliScriptPath() {
|
|
535
|
-
return process.argv[1];
|
|
536
|
-
}
|
|
537
|
-
async function readPid(filePath) {
|
|
538
|
-
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
539
|
-
if (!raw) {
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
const pid = Number.parseInt(raw.trim(), 10);
|
|
543
|
-
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
544
|
-
}
|
|
545
|
-
function isProcessAlive(pid) {
|
|
546
|
-
try {
|
|
547
|
-
process.kill(pid, 0);
|
|
548
|
-
return true;
|
|
549
|
-
} catch {
|
|
550
|
-
return false;
|
|
551
|
-
}
|
|
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
|
-
}
|
|
560
|
-
function wait(ms) {
|
|
561
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// src/autostart/autostart.ts
|
|
40
|
+
import { promisify } from "util";
|
|
565
41
|
var execFileAsync = promisify(execFile);
|
|
566
42
|
var MACOS_LABEL = "com.hermespilot.link";
|
|
567
43
|
async function enableAutostart() {
|
|
@@ -569,14 +45,14 @@ async function enableAutostart() {
|
|
|
569
45
|
if (!definition) {
|
|
570
46
|
return unsupportedStatus();
|
|
571
47
|
}
|
|
572
|
-
await
|
|
573
|
-
await
|
|
48
|
+
await mkdir(path.dirname(definition.filePath), { recursive: true, mode: 448 });
|
|
49
|
+
await writeFile(definition.filePath, definition.content, { mode: 384 });
|
|
574
50
|
if (definition.method === "systemd-user") {
|
|
575
|
-
await execFileAsync("systemctl", ["--user", "enable",
|
|
576
|
-
await
|
|
51
|
+
await execFileAsync("systemctl", ["--user", "enable", path.basename(definition.filePath)]).catch(async () => {
|
|
52
|
+
await rm(definition.filePath, { force: true }).catch(() => void 0);
|
|
577
53
|
const fallback = xdgAutostartDefinition();
|
|
578
|
-
await
|
|
579
|
-
await
|
|
54
|
+
await mkdir(path.dirname(fallback.filePath), { recursive: true, mode: 448 });
|
|
55
|
+
await writeFile(fallback.filePath, fallback.content, { mode: 384 });
|
|
580
56
|
});
|
|
581
57
|
}
|
|
582
58
|
return await getAutostartStatus();
|
|
@@ -585,9 +61,9 @@ async function disableAutostart() {
|
|
|
585
61
|
const definitions = await allAutostartDefinitions();
|
|
586
62
|
for (const definition of definitions) {
|
|
587
63
|
if (definition.method === "systemd-user") {
|
|
588
|
-
await execFileAsync("systemctl", ["--user", "disable",
|
|
64
|
+
await execFileAsync("systemctl", ["--user", "disable", path.basename(definition.filePath)]).catch(() => void 0);
|
|
589
65
|
}
|
|
590
|
-
await
|
|
66
|
+
await rm(definition.filePath, { force: true }).catch(() => void 0);
|
|
591
67
|
}
|
|
592
68
|
return await getAutostartStatus();
|
|
593
69
|
}
|
|
@@ -597,7 +73,7 @@ async function getAutostartStatus() {
|
|
|
597
73
|
return unsupportedStatus();
|
|
598
74
|
}
|
|
599
75
|
for (const definition of definitions) {
|
|
600
|
-
const content = await
|
|
76
|
+
const content = await readFile(definition.filePath, "utf8").catch(() => null);
|
|
601
77
|
if (content !== null) {
|
|
602
78
|
return {
|
|
603
79
|
supported: true,
|
|
@@ -648,7 +124,7 @@ async function hasSystemctlUser() {
|
|
|
648
124
|
}
|
|
649
125
|
}
|
|
650
126
|
function launchdDefinition() {
|
|
651
|
-
const filePath =
|
|
127
|
+
const filePath = path.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
|
|
652
128
|
return {
|
|
653
129
|
method: "launchd",
|
|
654
130
|
filePath,
|
|
@@ -674,7 +150,7 @@ function launchdDefinition() {
|
|
|
674
150
|
};
|
|
675
151
|
}
|
|
676
152
|
function systemdUserDefinition() {
|
|
677
|
-
const filePath =
|
|
153
|
+
const filePath = path.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
|
|
678
154
|
return {
|
|
679
155
|
method: "systemd-user",
|
|
680
156
|
filePath,
|
|
@@ -693,7 +169,7 @@ WantedBy=default.target
|
|
|
693
169
|
};
|
|
694
170
|
}
|
|
695
171
|
function xdgAutostartDefinition() {
|
|
696
|
-
const filePath =
|
|
172
|
+
const filePath = path.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
|
|
697
173
|
return {
|
|
698
174
|
method: "xdg-autostart",
|
|
699
175
|
filePath,
|
|
@@ -707,8 +183,8 @@ X-GNOME-Autostart-enabled=true
|
|
|
707
183
|
};
|
|
708
184
|
}
|
|
709
185
|
function windowsStartupDefinition() {
|
|
710
|
-
const appData = process.env.APPDATA ??
|
|
711
|
-
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");
|
|
712
188
|
return {
|
|
713
189
|
method: "windows-startup",
|
|
714
190
|
filePath,
|
|
@@ -955,12 +431,12 @@ function parseLanguage(value) {
|
|
|
955
431
|
|
|
956
432
|
// src/pairing/preflight.ts
|
|
957
433
|
import { access, stat } from "fs/promises";
|
|
958
|
-
import
|
|
434
|
+
import path2 from "path";
|
|
959
435
|
async function assertPairingPreflightReady(options = {}) {
|
|
960
436
|
const profileName = normalizeProfileName(options.profileName);
|
|
961
437
|
const hermesHome = resolveHermesProfileDir(profileName);
|
|
962
438
|
const configPath = resolveHermesConfigPath(profileName);
|
|
963
|
-
const envPath =
|
|
439
|
+
const envPath = path2.join(hermesHome, ".env");
|
|
964
440
|
const failures = [];
|
|
965
441
|
if (!await isDirectory(hermesHome)) {
|
|
966
442
|
failures.push({
|