@hermespilot/link 0.1.2 → 0.1.4
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 +8 -0
- package/dist/{chunk-E2BRK5JT.js → chunk-T35GPRKF.js} +288 -34
- package/dist/chunk-T35GPRKF.js.map +1 -0
- package/dist/cli/index.js +680 -107
- package/dist/cli/index.js.map +1 -1
- package/dist/http/app.d.ts +45 -2
- package/dist/http/app.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-E2BRK5JT.js.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -3,19 +3,530 @@ import {
|
|
|
3
3
|
LINK_COMMAND,
|
|
4
4
|
LINK_VERSION,
|
|
5
5
|
createApp,
|
|
6
|
+
createFileLogger,
|
|
6
7
|
ensureHermesApiServerKey,
|
|
7
8
|
ensureIdentity,
|
|
8
9
|
getIdentityStatus,
|
|
10
|
+
getLinkLogFile,
|
|
9
11
|
loadConfig,
|
|
10
12
|
loadIdentity,
|
|
11
13
|
preparePairing,
|
|
12
14
|
resolveRuntimePaths
|
|
13
|
-
} from "../chunk-
|
|
15
|
+
} from "../chunk-T35GPRKF.js";
|
|
14
16
|
|
|
15
17
|
// src/cli/index.ts
|
|
16
18
|
import { Command } from "commander";
|
|
17
19
|
import qrcode from "qrcode-terminal";
|
|
18
20
|
|
|
21
|
+
// src/autostart/autostart.ts
|
|
22
|
+
import { execFile } from "child_process";
|
|
23
|
+
import { mkdir as mkdir3, readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "fs/promises";
|
|
24
|
+
import os from "os";
|
|
25
|
+
import path2 from "path";
|
|
26
|
+
import { promisify } from "util";
|
|
27
|
+
|
|
28
|
+
// src/daemon/process.ts
|
|
29
|
+
import { spawn } from "child_process";
|
|
30
|
+
import { mkdir as mkdir2, open, readFile, rm as rm2 } from "fs/promises";
|
|
31
|
+
import path from "path";
|
|
32
|
+
|
|
33
|
+
// src/daemon/service.ts
|
|
34
|
+
import { createServer } from "http";
|
|
35
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
36
|
+
|
|
37
|
+
// src/relay/control-client.ts
|
|
38
|
+
import WebSocket from "ws";
|
|
39
|
+
function connectRelayControl(options) {
|
|
40
|
+
const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
|
|
41
|
+
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
42
|
+
wsUrl.searchParams.set("link_id", options.linkId);
|
|
43
|
+
const maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
|
|
44
|
+
const backoffBaseMs = options.backoffBaseMs ?? 1e3;
|
|
45
|
+
const backoffMaxMs = options.backoffMaxMs ?? 3e4;
|
|
46
|
+
let reconnectAttempts = 0;
|
|
47
|
+
let closedByUser = false;
|
|
48
|
+
let socket = null;
|
|
49
|
+
let retryTimer = null;
|
|
50
|
+
let abortControllers = /* @__PURE__ */ new Map();
|
|
51
|
+
const connect = () => {
|
|
52
|
+
options.onStatus?.({ state: "connecting", attempt: reconnectAttempts });
|
|
53
|
+
socket = new WebSocket(wsUrl, {
|
|
54
|
+
headers: {
|
|
55
|
+
"x-hermes-link-version": LINK_VERSION
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
socket.on("open", () => {
|
|
59
|
+
reconnectAttempts = 0;
|
|
60
|
+
options.onStatus?.({ state: "connected", attempt: reconnectAttempts });
|
|
61
|
+
});
|
|
62
|
+
socket.on("message", (raw) => {
|
|
63
|
+
if (!socket || typeof raw !== "string" && !Buffer.isBuffer(raw)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
|
|
67
|
+
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
68
|
+
socket?.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
socket.on("error", (error) => {
|
|
72
|
+
const message = error instanceof Error ? error.message : "Relay websocket error";
|
|
73
|
+
options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts, message });
|
|
74
|
+
});
|
|
75
|
+
socket.on("close", () => {
|
|
76
|
+
abortAll(abortControllers);
|
|
77
|
+
abortControllers = /* @__PURE__ */ new Map();
|
|
78
|
+
if (closedByUser) {
|
|
79
|
+
options.onStatus?.({ state: "disconnected", attempt: reconnectAttempts });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
83
|
+
options.onStatus?.({ state: "failed", attempt: reconnectAttempts, message: "Relay reconnect attempts exhausted" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
reconnectAttempts += 1;
|
|
87
|
+
const delay = computeBackoffMs(reconnectAttempts, backoffBaseMs, backoffMaxMs);
|
|
88
|
+
options.onStatus?.({ state: "retrying", attempt: reconnectAttempts, message: `Retrying in ${delay}ms` });
|
|
89
|
+
retryTimer = setTimeout(connect, delay);
|
|
90
|
+
retryTimer.unref?.();
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
connect();
|
|
94
|
+
return {
|
|
95
|
+
close() {
|
|
96
|
+
closedByUser = true;
|
|
97
|
+
if (retryTimer) {
|
|
98
|
+
clearTimeout(retryTimer);
|
|
99
|
+
}
|
|
100
|
+
abortAll(abortControllers);
|
|
101
|
+
socket?.close();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function abortAll(abortControllers) {
|
|
106
|
+
for (const controller of abortControllers.values()) {
|
|
107
|
+
controller.abort();
|
|
108
|
+
}
|
|
109
|
+
abortControllers.clear();
|
|
110
|
+
}
|
|
111
|
+
function computeBackoffMs(attempt, baseMs, maxMs) {
|
|
112
|
+
const exponential = Math.min(maxMs, baseMs * 2 ** Math.max(0, attempt - 1));
|
|
113
|
+
const jitter = Math.floor(Math.random() * Math.min(1e3, exponential * 0.2));
|
|
114
|
+
return exponential + jitter;
|
|
115
|
+
}
|
|
116
|
+
async function handleFrame(socket, raw, localPort, abortControllers) {
|
|
117
|
+
const frame = JSON.parse(raw);
|
|
118
|
+
if (frame.type === "http.cancel") {
|
|
119
|
+
abortControllers.get(frame.id)?.abort();
|
|
120
|
+
abortControllers.delete(frame.id);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (frame.type !== "http.request") {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const abortController = new AbortController();
|
|
127
|
+
abortControllers.set(frame.id, abortController);
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
|
|
130
|
+
method: frame.method,
|
|
131
|
+
headers: frame.headers ?? {},
|
|
132
|
+
body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
|
|
133
|
+
signal: abortController.signal
|
|
134
|
+
});
|
|
135
|
+
const headers = Object.fromEntries(response.headers.entries());
|
|
136
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
137
|
+
if (response.body && contentType.includes("text/event-stream")) {
|
|
138
|
+
socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
|
|
139
|
+
const reader = response.body.getReader();
|
|
140
|
+
while (true) {
|
|
141
|
+
const next = await reader.read();
|
|
142
|
+
if (next.done) {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
|
|
146
|
+
}
|
|
147
|
+
socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const body = Buffer.from(await response.arrayBuffer()).toString("base64");
|
|
151
|
+
socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
154
|
+
socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
|
|
155
|
+
} finally {
|
|
156
|
+
abortControllers.delete(frame.id);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/daemon/service.ts
|
|
161
|
+
async function startLinkService(options = {}) {
|
|
162
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
163
|
+
const logger = createFileLogger({ paths });
|
|
164
|
+
const [identity, config] = await Promise.all([loadIdentity(paths), loadConfig(paths)]);
|
|
165
|
+
await logger.info("service_starting", {
|
|
166
|
+
port: config.port,
|
|
167
|
+
mode: identity?.link_id ? "paired" : "local-only"
|
|
168
|
+
});
|
|
169
|
+
const app = await createApp({ paths, logger, onPairingClaimed: options.onPairingClaimed });
|
|
170
|
+
const server = createServer(app.callback());
|
|
171
|
+
try {
|
|
172
|
+
await listenServer(server, config.port);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
await logger.error("service_start_failed", {
|
|
175
|
+
port: config.port,
|
|
176
|
+
error: error instanceof Error ? error.message : String(error)
|
|
177
|
+
});
|
|
178
|
+
await logger.flush();
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
server.on("error", (error) => {
|
|
182
|
+
void logger.error("service_error", { error: error.message });
|
|
183
|
+
});
|
|
184
|
+
void logger.info("service_started", {
|
|
185
|
+
port: config.port,
|
|
186
|
+
link_id: identity?.link_id ?? null
|
|
187
|
+
});
|
|
188
|
+
let relay = null;
|
|
189
|
+
if (identity?.link_id) {
|
|
190
|
+
relay = connectRelayControl({
|
|
191
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
192
|
+
linkId: identity.link_id,
|
|
193
|
+
localPort: config.port,
|
|
194
|
+
maxReconnectAttempts: options.relayMaxReconnectAttempts ?? 5,
|
|
195
|
+
backoffBaseMs: 1e3,
|
|
196
|
+
backoffMaxMs: 3e4,
|
|
197
|
+
onStatus: (status) => {
|
|
198
|
+
void logger.info("relay_status", status);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
void logger.info("relay_skipped", { reason: "link_not_paired" });
|
|
203
|
+
}
|
|
204
|
+
if (options.writePidFile) {
|
|
205
|
+
await writePidFile(paths);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
async close() {
|
|
209
|
+
relay?.close();
|
|
210
|
+
await closeServer(server);
|
|
211
|
+
await logger.info("service_stopped");
|
|
212
|
+
await logger.flush();
|
|
213
|
+
if (options.writePidFile) {
|
|
214
|
+
await rm(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function pidFilePath(paths = resolveRuntimePaths()) {
|
|
220
|
+
return `${paths.runDir}/hermeslink.pid`;
|
|
221
|
+
}
|
|
222
|
+
async function writePidFile(paths) {
|
|
223
|
+
await mkdir(paths.runDir, { recursive: true, mode: 448 });
|
|
224
|
+
await writeFile(pidFilePath(paths), `${process.pid}
|
|
225
|
+
`, { mode: 384 });
|
|
226
|
+
}
|
|
227
|
+
async function closeServer(server) {
|
|
228
|
+
await new Promise((resolve, reject) => {
|
|
229
|
+
server.close((error) => {
|
|
230
|
+
if (error) {
|
|
231
|
+
reject(error);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
resolve();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async function listenServer(server, port) {
|
|
239
|
+
await new Promise((resolve, reject) => {
|
|
240
|
+
const cleanup = () => {
|
|
241
|
+
server.off("error", onError);
|
|
242
|
+
server.off("listening", onListening);
|
|
243
|
+
};
|
|
244
|
+
const onError = (error) => {
|
|
245
|
+
cleanup();
|
|
246
|
+
reject(error);
|
|
247
|
+
};
|
|
248
|
+
const onListening = () => {
|
|
249
|
+
cleanup();
|
|
250
|
+
resolve();
|
|
251
|
+
};
|
|
252
|
+
server.once("error", onError);
|
|
253
|
+
server.once("listening", onListening);
|
|
254
|
+
server.listen(port);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/daemon/process.ts
|
|
259
|
+
async function startDaemonProcess(paths = resolveRuntimePaths()) {
|
|
260
|
+
const status = await getDaemonStatus(paths);
|
|
261
|
+
if (status.running) {
|
|
262
|
+
return status;
|
|
263
|
+
}
|
|
264
|
+
await mkdir2(paths.logsDir, { recursive: true, mode: 448 });
|
|
265
|
+
await mkdir2(paths.runDir, { recursive: true, mode: 448 });
|
|
266
|
+
const log = await open(daemonLogFile(paths), "a", 384);
|
|
267
|
+
const scriptPath = currentCliScriptPath();
|
|
268
|
+
const child = spawn(process.execPath, [scriptPath, "daemon", "--foreground"], {
|
|
269
|
+
detached: true,
|
|
270
|
+
stdio: ["ignore", log.fd, log.fd],
|
|
271
|
+
env: process.env
|
|
272
|
+
});
|
|
273
|
+
child.unref();
|
|
274
|
+
await log.close();
|
|
275
|
+
for (let index = 0; index < 12; index += 1) {
|
|
276
|
+
await wait(250);
|
|
277
|
+
const next = await getDaemonStatus(paths);
|
|
278
|
+
if (next.running) {
|
|
279
|
+
return next;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return await getDaemonStatus(paths);
|
|
283
|
+
}
|
|
284
|
+
async function stopDaemonProcess(paths = resolveRuntimePaths()) {
|
|
285
|
+
const status = await getDaemonStatus(paths);
|
|
286
|
+
if (!status.running || !status.pid) {
|
|
287
|
+
return status;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
process.kill(status.pid, "SIGTERM");
|
|
291
|
+
} catch {
|
|
292
|
+
await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
293
|
+
return await getDaemonStatus(paths);
|
|
294
|
+
}
|
|
295
|
+
for (let index = 0; index < 20; index += 1) {
|
|
296
|
+
await wait(250);
|
|
297
|
+
if (!isProcessAlive(status.pid)) {
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (!isProcessAlive(status.pid)) {
|
|
302
|
+
await rm2(pidFilePath(paths), { force: true }).catch(() => void 0);
|
|
303
|
+
}
|
|
304
|
+
return await getDaemonStatus(paths);
|
|
305
|
+
}
|
|
306
|
+
async function getDaemonStatus(paths = resolveRuntimePaths()) {
|
|
307
|
+
const pidFile = pidFilePath(paths);
|
|
308
|
+
const pid = await readPid(pidFile);
|
|
309
|
+
if (pid && !isProcessAlive(pid)) {
|
|
310
|
+
await rm2(pidFile, { force: true }).catch(() => void 0);
|
|
311
|
+
return {
|
|
312
|
+
running: false,
|
|
313
|
+
pid: null,
|
|
314
|
+
pidFile,
|
|
315
|
+
logFile: daemonLogFile(paths)
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
running: Boolean(pid),
|
|
320
|
+
pid,
|
|
321
|
+
pidFile,
|
|
322
|
+
logFile: daemonLogFile(paths)
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function daemonLogFile(paths = resolveRuntimePaths()) {
|
|
326
|
+
return path.join(paths.logsDir, "daemon.log");
|
|
327
|
+
}
|
|
328
|
+
function currentCliScriptPath() {
|
|
329
|
+
return process.argv[1];
|
|
330
|
+
}
|
|
331
|
+
async function readPid(filePath) {
|
|
332
|
+
const raw = await readFile(filePath, "utf8").catch(() => null);
|
|
333
|
+
if (!raw) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
337
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
338
|
+
}
|
|
339
|
+
function isProcessAlive(pid) {
|
|
340
|
+
try {
|
|
341
|
+
process.kill(pid, 0);
|
|
342
|
+
return true;
|
|
343
|
+
} catch {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function wait(ms) {
|
|
348
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// src/autostart/autostart.ts
|
|
352
|
+
var execFileAsync = promisify(execFile);
|
|
353
|
+
var MACOS_LABEL = "com.hermespilot.link";
|
|
354
|
+
async function enableAutostart() {
|
|
355
|
+
const definition = await resolveAutostartDefinition();
|
|
356
|
+
if (!definition) {
|
|
357
|
+
return unsupportedStatus();
|
|
358
|
+
}
|
|
359
|
+
await mkdir3(path2.dirname(definition.filePath), { recursive: true, mode: 448 });
|
|
360
|
+
await writeFile2(definition.filePath, definition.content, { mode: 384 });
|
|
361
|
+
if (definition.method === "systemd-user") {
|
|
362
|
+
await execFileAsync("systemctl", ["--user", "enable", path2.basename(definition.filePath)]).catch(async () => {
|
|
363
|
+
await rm3(definition.filePath, { force: true }).catch(() => void 0);
|
|
364
|
+
const fallback = xdgAutostartDefinition();
|
|
365
|
+
await mkdir3(path2.dirname(fallback.filePath), { recursive: true, mode: 448 });
|
|
366
|
+
await writeFile2(fallback.filePath, fallback.content, { mode: 384 });
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return await getAutostartStatus();
|
|
370
|
+
}
|
|
371
|
+
async function disableAutostart() {
|
|
372
|
+
const definitions = await allAutostartDefinitions();
|
|
373
|
+
for (const definition of definitions) {
|
|
374
|
+
if (definition.method === "systemd-user") {
|
|
375
|
+
await execFileAsync("systemctl", ["--user", "disable", path2.basename(definition.filePath)]).catch(() => void 0);
|
|
376
|
+
}
|
|
377
|
+
await rm3(definition.filePath, { force: true }).catch(() => void 0);
|
|
378
|
+
}
|
|
379
|
+
return await getAutostartStatus();
|
|
380
|
+
}
|
|
381
|
+
async function getAutostartStatus() {
|
|
382
|
+
const definitions = await allAutostartDefinitions();
|
|
383
|
+
if (definitions.length === 0) {
|
|
384
|
+
return unsupportedStatus();
|
|
385
|
+
}
|
|
386
|
+
for (const definition of definitions) {
|
|
387
|
+
const content = await readFile2(definition.filePath, "utf8").catch(() => null);
|
|
388
|
+
if (content !== null) {
|
|
389
|
+
return {
|
|
390
|
+
supported: true,
|
|
391
|
+
enabled: true,
|
|
392
|
+
method: definition.method,
|
|
393
|
+
filePath: definition.filePath
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const primary = definitions[0];
|
|
398
|
+
return {
|
|
399
|
+
supported: true,
|
|
400
|
+
enabled: false,
|
|
401
|
+
method: primary.method,
|
|
402
|
+
filePath: primary.filePath
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
async function resolveAutostartDefinition() {
|
|
406
|
+
if (process.platform === "darwin") {
|
|
407
|
+
return launchdDefinition();
|
|
408
|
+
}
|
|
409
|
+
if (process.platform === "win32") {
|
|
410
|
+
return windowsStartupDefinition();
|
|
411
|
+
}
|
|
412
|
+
if (process.platform === "linux") {
|
|
413
|
+
return await hasSystemctlUser() ? systemdUserDefinition() : xdgAutostartDefinition();
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
async function allAutostartDefinitions() {
|
|
418
|
+
if (process.platform === "darwin") {
|
|
419
|
+
return [launchdDefinition()];
|
|
420
|
+
}
|
|
421
|
+
if (process.platform === "win32") {
|
|
422
|
+
return [windowsStartupDefinition()];
|
|
423
|
+
}
|
|
424
|
+
if (process.platform === "linux") {
|
|
425
|
+
return [systemdUserDefinition(), xdgAutostartDefinition()];
|
|
426
|
+
}
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
async function hasSystemctlUser() {
|
|
430
|
+
try {
|
|
431
|
+
await execFileAsync("systemctl", ["--user", "show-environment"]);
|
|
432
|
+
return true;
|
|
433
|
+
} catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function launchdDefinition() {
|
|
438
|
+
const filePath = path2.join(os.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
|
|
439
|
+
return {
|
|
440
|
+
method: "launchd",
|
|
441
|
+
filePath,
|
|
442
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
443
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
444
|
+
<plist version="1.0">
|
|
445
|
+
<dict>
|
|
446
|
+
<key>Label</key>
|
|
447
|
+
<string>${MACOS_LABEL}</string>
|
|
448
|
+
<key>ProgramArguments</key>
|
|
449
|
+
<array>
|
|
450
|
+
<string>${xmlEscape(process.execPath)}</string>
|
|
451
|
+
<string>${xmlEscape(currentCliScriptPath())}</string>
|
|
452
|
+
<string>daemon</string>
|
|
453
|
+
<string>--foreground</string>
|
|
454
|
+
</array>
|
|
455
|
+
<key>RunAtLoad</key>
|
|
456
|
+
<true/>
|
|
457
|
+
<key>KeepAlive</key>
|
|
458
|
+
<false/>
|
|
459
|
+
<key>StandardOutPath</key>
|
|
460
|
+
<string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
|
|
461
|
+
<key>StandardErrorPath</key>
|
|
462
|
+
<string>${xmlEscape(path2.join(os.homedir(), ".hermeslink", "logs", "daemon.log"))}</string>
|
|
463
|
+
</dict>
|
|
464
|
+
</plist>
|
|
465
|
+
`
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
function systemdUserDefinition() {
|
|
469
|
+
const filePath = path2.join(os.homedir(), ".config", "systemd", "user", "hermeslink.service");
|
|
470
|
+
return {
|
|
471
|
+
method: "systemd-user",
|
|
472
|
+
filePath,
|
|
473
|
+
content: `[Unit]
|
|
474
|
+
Description=Hermes Link
|
|
475
|
+
After=network-online.target
|
|
476
|
+
|
|
477
|
+
[Service]
|
|
478
|
+
Type=simple
|
|
479
|
+
ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon --foreground
|
|
480
|
+
Restart=no
|
|
481
|
+
|
|
482
|
+
[Install]
|
|
483
|
+
WantedBy=default.target
|
|
484
|
+
`
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function xdgAutostartDefinition() {
|
|
488
|
+
const filePath = path2.join(os.homedir(), ".config", "autostart", "hermeslink.desktop");
|
|
489
|
+
return {
|
|
490
|
+
method: "xdg-autostart",
|
|
491
|
+
filePath,
|
|
492
|
+
content: `[Desktop Entry]
|
|
493
|
+
Type=Application
|
|
494
|
+
Name=Hermes Link
|
|
495
|
+
Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon --foreground
|
|
496
|
+
Terminal=false
|
|
497
|
+
X-GNOME-Autostart-enabled=true
|
|
498
|
+
`
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function windowsStartupDefinition() {
|
|
502
|
+
const appData = process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming");
|
|
503
|
+
const filePath = path2.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "HermesLink.cmd");
|
|
504
|
+
return {
|
|
505
|
+
method: "windows-startup",
|
|
506
|
+
filePath,
|
|
507
|
+
content: `@echo off\r
|
|
508
|
+
start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon --foreground\r
|
|
509
|
+
`
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function unsupportedStatus() {
|
|
513
|
+
return {
|
|
514
|
+
supported: false,
|
|
515
|
+
enabled: false,
|
|
516
|
+
method: "unsupported",
|
|
517
|
+
filePath: null
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function xmlEscape(value) {
|
|
521
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
522
|
+
}
|
|
523
|
+
function systemdQuote(value) {
|
|
524
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
525
|
+
}
|
|
526
|
+
function desktopQuote(value) {
|
|
527
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
528
|
+
}
|
|
529
|
+
|
|
19
530
|
// src/i18n.ts
|
|
20
531
|
var messages = {
|
|
21
532
|
en: {
|
|
@@ -29,10 +540,30 @@ var messages = {
|
|
|
29
540
|
"status.linkId": "Link ID: {value}",
|
|
30
541
|
"status.notPaired": "not paired",
|
|
31
542
|
"start.description": "Start Hermes Link daemon",
|
|
543
|
+
"start.backgroundStarted": "Hermes Link is running in the background. PID: {pid}",
|
|
544
|
+
"start.alreadyRunning": "Hermes Link is already running. PID: {pid}",
|
|
32
545
|
"start.notPaired": "Hermes Link is not paired yet. Starting in local-only maintenance mode.",
|
|
33
546
|
"start.notPaired.detail": "Relay, Server polling, and LAN entrypoints stay disabled until you run `hermeslink pair`.",
|
|
34
547
|
"start.listening": "Hermes Link API listening on http://127.0.0.1:{port}",
|
|
35
548
|
"start.relayConnecting": "Relay control connecting for {linkId}",
|
|
549
|
+
"stop.description": "Stop the background Hermes Link daemon",
|
|
550
|
+
"stop.stopped": "Hermes Link stopped.",
|
|
551
|
+
"stop.notRunning": "Hermes Link is not running.",
|
|
552
|
+
"restart.description": "Restart the background Hermes Link daemon",
|
|
553
|
+
"daemon.description": "Run Hermes Link in the foreground",
|
|
554
|
+
"daemon.foreground": "Hermes Link foreground daemon is running. Press Ctrl+C to stop.",
|
|
555
|
+
"logs.description": "Show Hermes Link log paths",
|
|
556
|
+
"logs.servicePath": "Service log: {path}",
|
|
557
|
+
"logs.daemonPath": "Daemon stdout/stderr log: {path}",
|
|
558
|
+
"autostart.description": "Manage boot autostart",
|
|
559
|
+
"autostart.on.description": "Enable boot autostart",
|
|
560
|
+
"autostart.off.description": "Disable boot autostart",
|
|
561
|
+
"autostart.status.description": "Show boot autostart status",
|
|
562
|
+
"autostart.enabled": "Boot autostart enabled via {method}: {path}",
|
|
563
|
+
"autostart.disabled": "Boot autostart disabled.",
|
|
564
|
+
"autostart.status.enabled": "Boot autostart: enabled via {method}: {path}",
|
|
565
|
+
"autostart.status.disabled": "Boot autostart: disabled. Method: {method}. File: {path}",
|
|
566
|
+
"autostart.unsupported": "Boot autostart is not supported on this platform yet.",
|
|
36
567
|
"pair.description": "Create a Hermes Link pairing session",
|
|
37
568
|
"pair.preparing": "Preparing pairing session through HermesPilot Server and Relay...",
|
|
38
569
|
"pair.server": "Server: {url}",
|
|
@@ -42,6 +573,8 @@ var messages = {
|
|
|
42
573
|
"pair.localApi": "Local API: http://127.0.0.1:{port}",
|
|
43
574
|
"pair.scan": "Scan this QR code with the HermesPilot App:",
|
|
44
575
|
"pair.expires": "Pairing expires in 10 minutes. Press Ctrl+C to stop Hermes Link.",
|
|
576
|
+
"pair.claimed": "Pairing succeeded. Starting Hermes Link in the background...",
|
|
577
|
+
"pair.autostartFailed": "Pairing succeeded, but boot autostart could not be enabled: {message}",
|
|
45
578
|
"doctor.description": "Run local diagnostics",
|
|
46
579
|
"doctor.identityOk": "Runtime identity: OK",
|
|
47
580
|
"doctor.installId": "Install ID: {value}",
|
|
@@ -52,6 +585,7 @@ var messages = {
|
|
|
52
585
|
"error.relayLinkInvalid": "Relay did not return a valid link_id.",
|
|
53
586
|
"error.relayEmpty": "Relay returned an empty response.",
|
|
54
587
|
"error.serverHttp": "HermesPilot Server request failed with HTTP {status}.",
|
|
588
|
+
"error.portInUse": "Local port {port} is already in use. Stop the existing Hermes Link process, then run `hermeslink pair` again.",
|
|
55
589
|
"error.pairingRequires": "Pairing needs HermesPilot Server and Relay, but this command could not start a complete pairing session.",
|
|
56
590
|
"error.pairingRequires.detail": "The deployed services may be healthy, but the installed Link package must call Server for a short-lived relay bootstrap token before it can request a link_id."
|
|
57
591
|
},
|
|
@@ -66,10 +600,30 @@ var messages = {
|
|
|
66
600
|
"status.linkId": "Link ID\uFF1A{value}",
|
|
67
601
|
"status.notPaired": "\u5C1A\u672A\u914D\u5BF9",
|
|
68
602
|
"start.description": "\u542F\u52A8 Hermes Link \u670D\u52A1",
|
|
603
|
+
"start.backgroundStarted": "Hermes Link \u5DF2\u5728\u540E\u53F0\u8FD0\u884C\u3002PID\uFF1A{pid}",
|
|
604
|
+
"start.alreadyRunning": "Hermes Link \u5DF2\u7ECF\u5728\u8FD0\u884C\u3002PID\uFF1A{pid}",
|
|
69
605
|
"start.notPaired": "Hermes Link \u8FD8\u6CA1\u6709\u914D\u5BF9\uFF0C\u5C06\u4EE5\u672C\u5730\u7EF4\u62A4\u6A21\u5F0F\u542F\u52A8\u3002",
|
|
70
606
|
"start.notPaired.detail": "\u5728\u4F60\u8FD0\u884C `hermeslink pair` \u524D\uFF0CRelay\u3001Server \u8F6E\u8BE2\u548C\u5C40\u57DF\u7F51\u5165\u53E3\u90FD\u4F1A\u4FDD\u6301\u5173\u95ED\u3002",
|
|
71
607
|
"start.listening": "Hermes Link API \u6B63\u5728\u76D1\u542C http://127.0.0.1:{port}",
|
|
72
608
|
"start.relayConnecting": "\u6B63\u5728\u4E3A {linkId} \u8FDE\u63A5 Relay \u63A7\u5236\u901A\u9053",
|
|
609
|
+
"stop.description": "\u505C\u6B62\u540E\u53F0 Hermes Link \u670D\u52A1",
|
|
610
|
+
"stop.stopped": "Hermes Link \u5DF2\u505C\u6B62\u3002",
|
|
611
|
+
"stop.notRunning": "Hermes Link \u6CA1\u6709\u5728\u8FD0\u884C\u3002",
|
|
612
|
+
"restart.description": "\u91CD\u542F\u540E\u53F0 Hermes Link \u670D\u52A1",
|
|
613
|
+
"daemon.description": "\u4EE5\u524D\u53F0\u65B9\u5F0F\u8FD0\u884C Hermes Link",
|
|
614
|
+
"daemon.foreground": "Hermes Link \u524D\u53F0\u670D\u52A1\u6B63\u5728\u8FD0\u884C\u3002\u6309 Ctrl+C \u505C\u6B62\u3002",
|
|
615
|
+
"logs.description": "\u663E\u793A Hermes Link \u65E5\u5FD7\u8DEF\u5F84",
|
|
616
|
+
"logs.servicePath": "\u670D\u52A1\u65E5\u5FD7\uFF1A{path}",
|
|
617
|
+
"logs.daemonPath": "Daemon \u6807\u51C6\u8F93\u51FA/\u9519\u8BEF\u65E5\u5FD7\uFF1A{path}",
|
|
618
|
+
"autostart.description": "\u7BA1\u7406\u5F00\u673A\u81EA\u542F",
|
|
619
|
+
"autostart.on.description": "\u542F\u7528\u5F00\u673A\u81EA\u542F",
|
|
620
|
+
"autostart.off.description": "\u5173\u95ED\u5F00\u673A\u81EA\u542F",
|
|
621
|
+
"autostart.status.description": "\u67E5\u770B\u5F00\u673A\u81EA\u542F\u72B6\u6001",
|
|
622
|
+
"autostart.enabled": "\u5DF2\u542F\u7528\u5F00\u673A\u81EA\u542F\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
|
|
623
|
+
"autostart.disabled": "\u5DF2\u5173\u95ED\u5F00\u673A\u81EA\u542F\u3002",
|
|
624
|
+
"autostart.status.enabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u5DF2\u542F\u7528\uFF0C\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
|
|
625
|
+
"autostart.status.disabled": "\u5F00\u673A\u81EA\u542F\uFF1A\u672A\u542F\u7528\u3002\u65B9\u5F0F\uFF1A{method}\uFF0C\u6587\u4EF6\uFF1A{path}",
|
|
626
|
+
"autostart.unsupported": "\u5F53\u524D\u5E73\u53F0\u6682\u4E0D\u652F\u6301\u5F00\u673A\u81EA\u542F\u3002",
|
|
73
627
|
"pair.description": "\u521B\u5EFA Hermes Link \u914D\u5BF9\u4F1A\u8BDD",
|
|
74
628
|
"pair.preparing": "\u6B63\u5728\u901A\u8FC7 HermesPilot Server \u548C Relay \u521B\u5EFA\u914D\u5BF9\u4F1A\u8BDD...",
|
|
75
629
|
"pair.server": "Server\uFF1A{url}",
|
|
@@ -79,6 +633,8 @@ var messages = {
|
|
|
79
633
|
"pair.localApi": "\u672C\u5730 API\uFF1Ahttp://127.0.0.1:{port}",
|
|
80
634
|
"pair.scan": "\u8BF7\u4F7F\u7528 HermesPilot App \u626B\u63CF\u8FD9\u4E2A\u4E8C\u7EF4\u7801\uFF1A",
|
|
81
635
|
"pair.expires": "\u914D\u5BF9\u4F1A\u8BDD 10 \u5206\u949F\u540E\u8FC7\u671F\u3002\u6309 Ctrl+C \u505C\u6B62 Hermes Link\u3002",
|
|
636
|
+
"pair.claimed": "\u914D\u5BF9\u5DF2\u6210\u529F\u3002\u6B63\u5728\u628A Hermes Link \u5207\u6362\u5230\u540E\u53F0\u8FD0\u884C...",
|
|
637
|
+
"pair.autostartFailed": "\u914D\u5BF9\u5DF2\u6210\u529F\uFF0C\u4F46\u542F\u7528\u5F00\u673A\u81EA\u542F\u5931\u8D25\uFF1A{message}",
|
|
82
638
|
"doctor.description": "\u8FD0\u884C\u672C\u673A\u8BCA\u65AD",
|
|
83
639
|
"doctor.identityOk": "\u8FD0\u884C\u8EAB\u4EFD\uFF1A\u6B63\u5E38",
|
|
84
640
|
"doctor.installId": "Install ID\uFF1A{value}",
|
|
@@ -89,6 +645,7 @@ var messages = {
|
|
|
89
645
|
"error.relayLinkInvalid": "Relay \u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684 link_id\u3002",
|
|
90
646
|
"error.relayEmpty": "Relay \u8FD4\u56DE\u4E86\u7A7A\u54CD\u5E94\u3002",
|
|
91
647
|
"error.serverHttp": "HermesPilot Server \u8BF7\u6C42\u5931\u8D25\uFF0CHTTP \u72B6\u6001\u7801\uFF1A{status}\u3002",
|
|
648
|
+
"error.portInUse": "\u672C\u5730\u7AEF\u53E3 {port} \u5DF2\u88AB\u5360\u7528\u3002\u8BF7\u5148\u505C\u6B62\u5DF2\u6709\u7684 Hermes Link \u8FDB\u7A0B\uFF0C\u7136\u540E\u91CD\u65B0\u8FD0\u884C `hermeslink pair`\u3002",
|
|
92
649
|
"error.pairingRequires": "\u914D\u5BF9\u9700\u8981 HermesPilot Server \u548C Relay\uFF0C\u4F46\u5F53\u524D\u547D\u4EE4\u6CA1\u6709\u80FD\u542F\u52A8\u5B8C\u6574\u914D\u5BF9\u4F1A\u8BDD\u3002",
|
|
93
650
|
"error.pairingRequires.detail": "\u4E91\u7AEF\u670D\u52A1\u53EF\u4EE5\u662F\u5DF2\u90E8\u7F72\u4E14\u5065\u5EB7\u7684\uFF1B\u672C\u673A Link \u4ECD\u5FC5\u987B\u5148\u5411 Server \u7533\u8BF7\u77ED\u671F relay bootstrap token\uFF0C\u624D\u80FD\u518D\u5411 Relay \u7533\u8BF7 link_id\u3002"
|
|
94
651
|
}
|
|
@@ -143,6 +700,10 @@ function translateKnownError(message, language) {
|
|
|
143
700
|
if (message === "Relay returned an empty response") {
|
|
144
701
|
return translate(language, "error.relayEmpty");
|
|
145
702
|
}
|
|
703
|
+
const portInUse = /^listen EADDRINUSE: address already in use .*:(?<port>\d+)$/u.exec(message);
|
|
704
|
+
if (portInUse?.groups?.port) {
|
|
705
|
+
return translate(language, "error.portInUse", { port: portInUse.groups.port });
|
|
706
|
+
}
|
|
146
707
|
const serverHttp = /^HermesPilot Server request failed with HTTP (?<status>\d+)$/u.exec(message);
|
|
147
708
|
if (serverHttp?.groups?.status) {
|
|
148
709
|
return translate(language, "error.serverHttp", { status: serverHttp.groups.status });
|
|
@@ -166,83 +727,6 @@ function parseLanguage(value) {
|
|
|
166
727
|
return null;
|
|
167
728
|
}
|
|
168
729
|
|
|
169
|
-
// src/relay/control-client.ts
|
|
170
|
-
import WebSocket from "ws";
|
|
171
|
-
function connectRelayControl(options) {
|
|
172
|
-
const wsUrl = new URL(`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link/connect`);
|
|
173
|
-
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
|
174
|
-
wsUrl.searchParams.set("link_id", options.linkId);
|
|
175
|
-
const socket = new WebSocket(wsUrl, {
|
|
176
|
-
headers: {
|
|
177
|
-
"x-hermes-link-version": LINK_VERSION
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
const abortControllers = /* @__PURE__ */ new Map();
|
|
181
|
-
socket.on("message", (raw) => {
|
|
182
|
-
if (typeof raw !== "string" && !Buffer.isBuffer(raw)) {
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
void handleFrame(socket, String(raw), options.localPort, abortControllers).catch((error) => {
|
|
186
|
-
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
187
|
-
socket.send(JSON.stringify({ type: "http.error", id: "unknown", status: 502, message }));
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
socket.on("close", () => {
|
|
191
|
-
for (const controller of abortControllers.values()) {
|
|
192
|
-
controller.abort();
|
|
193
|
-
}
|
|
194
|
-
abortControllers.clear();
|
|
195
|
-
});
|
|
196
|
-
return {
|
|
197
|
-
close() {
|
|
198
|
-
socket.close();
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
async function handleFrame(socket, raw, localPort, abortControllers) {
|
|
203
|
-
const frame = JSON.parse(raw);
|
|
204
|
-
if (frame.type === "http.cancel") {
|
|
205
|
-
abortControllers.get(frame.id)?.abort();
|
|
206
|
-
abortControllers.delete(frame.id);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
if (frame.type !== "http.request") {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const abortController = new AbortController();
|
|
213
|
-
abortControllers.set(frame.id, abortController);
|
|
214
|
-
try {
|
|
215
|
-
const response = await fetch(`http://127.0.0.1:${localPort}${frame.path}`, {
|
|
216
|
-
method: frame.method,
|
|
217
|
-
headers: frame.headers ?? {},
|
|
218
|
-
body: frame.bodyBase64 ? Buffer.from(frame.bodyBase64, "base64") : void 0,
|
|
219
|
-
signal: abortController.signal
|
|
220
|
-
});
|
|
221
|
-
const headers = Object.fromEntries(response.headers.entries());
|
|
222
|
-
const contentType = response.headers.get("content-type") ?? "";
|
|
223
|
-
if (response.body && contentType.includes("text/event-stream")) {
|
|
224
|
-
socket.send(JSON.stringify({ type: "http.stream.start", id: frame.id, status: response.status, headers }));
|
|
225
|
-
const reader = response.body.getReader();
|
|
226
|
-
while (true) {
|
|
227
|
-
const next = await reader.read();
|
|
228
|
-
if (next.done) {
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
231
|
-
socket.send(JSON.stringify({ type: "http.stream.chunk", id: frame.id, bodyBase64: Buffer.from(next.value).toString("base64") }));
|
|
232
|
-
}
|
|
233
|
-
socket.send(JSON.stringify({ type: "http.stream.end", id: frame.id }));
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
const body = Buffer.from(await response.arrayBuffer()).toString("base64");
|
|
237
|
-
socket.send(JSON.stringify({ type: "http.response", id: frame.id, status: response.status, headers, bodyBase64: body }));
|
|
238
|
-
} catch (error) {
|
|
239
|
-
const message = error instanceof Error ? error.message : "Relay request failed";
|
|
240
|
-
socket.send(JSON.stringify({ type: "http.error", id: frame.id, status: 502, message }));
|
|
241
|
-
} finally {
|
|
242
|
-
abortControllers.delete(frame.id);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
730
|
// src/cli/index.ts
|
|
247
731
|
var program = new Command();
|
|
248
732
|
var helpLanguage = detectSystemLanguage();
|
|
@@ -276,26 +760,44 @@ program.command("status").option("--json", helpText("status.json")).description(
|
|
|
276
760
|
console.log(t("status.linkId", { value: payload.identity?.linkId ?? t("status.notPaired") }));
|
|
277
761
|
});
|
|
278
762
|
program.command("start").description(helpText("start.description")).action(async () => {
|
|
279
|
-
const [
|
|
763
|
+
const [config, status] = await Promise.all([loadConfig(), getDaemonStatus()]);
|
|
280
764
|
const language = resolveLanguage(config.language);
|
|
281
765
|
const t = translate.bind(null, language);
|
|
282
|
-
if (
|
|
283
|
-
console.log(t("start.
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
766
|
+
if (status.running && status.pid) {
|
|
767
|
+
console.log(t("start.alreadyRunning", { pid: status.pid }));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const nextStatus = await startDaemonProcess();
|
|
771
|
+
console.log(t("start.backgroundStarted", { pid: nextStatus.pid ?? "unknown" }));
|
|
772
|
+
});
|
|
773
|
+
program.command("stop").description(helpText("stop.description")).action(async () => {
|
|
774
|
+
const config = await loadConfig();
|
|
775
|
+
const language = resolveLanguage(config.language);
|
|
776
|
+
const t = translate.bind(null, language);
|
|
777
|
+
const before = await getDaemonStatus();
|
|
778
|
+
if (!before.running) {
|
|
779
|
+
console.log(t("stop.notRunning"));
|
|
780
|
+
return;
|
|
295
781
|
}
|
|
782
|
+
await stopDaemonProcess();
|
|
783
|
+
console.log(t("stop.stopped"));
|
|
784
|
+
});
|
|
785
|
+
program.command("restart").description(helpText("restart.description")).action(async () => {
|
|
786
|
+
const config = await loadConfig();
|
|
787
|
+
const language = resolveLanguage(config.language);
|
|
788
|
+
const t = translate.bind(null, language);
|
|
789
|
+
await stopDaemonProcess();
|
|
790
|
+
const status = await startDaemonProcess();
|
|
791
|
+
console.log(t("start.backgroundStarted", { pid: status.pid ?? "unknown" }));
|
|
792
|
+
});
|
|
793
|
+
program.command("daemon").option("--foreground", "run in foreground").description(helpText("daemon.description")).action(async () => {
|
|
794
|
+
const config = await loadConfig();
|
|
795
|
+
const language = resolveLanguage(config.language);
|
|
796
|
+
const t = translate.bind(null, language);
|
|
797
|
+
const service = await startLinkService({ writePidFile: true });
|
|
798
|
+
console.log(t("daemon.foreground"));
|
|
296
799
|
await waitForShutdown(async () => {
|
|
297
|
-
|
|
298
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
800
|
+
await service.close();
|
|
299
801
|
});
|
|
300
802
|
});
|
|
301
803
|
program.command("pair").description(helpText("pair.description")).action(async () => {
|
|
@@ -308,11 +810,10 @@ program.command("pair").description(helpText("pair.description")).action(async (
|
|
|
308
810
|
console.log(t("pair.relay", { url: config.relayBaseUrl }));
|
|
309
811
|
await ensureIdentity(paths);
|
|
310
812
|
const prepared = await preparePairing(paths);
|
|
311
|
-
const
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
localPort: config.port
|
|
813
|
+
const pairingClaimed = createDeferred();
|
|
814
|
+
const service = await startLinkService({
|
|
815
|
+
paths,
|
|
816
|
+
onPairingClaimed: () => pairingClaimed.resolve()
|
|
316
817
|
});
|
|
317
818
|
const qrValue = JSON.stringify(prepared.qrPayload);
|
|
318
819
|
console.log(t("pair.linkId", { value: prepared.linkId }));
|
|
@@ -321,10 +822,65 @@ program.command("pair").description(helpText("pair.description")).action(async (
|
|
|
321
822
|
console.log(t("pair.scan"));
|
|
322
823
|
qrcode.generate(qrValue, { small: true });
|
|
323
824
|
console.log(t("pair.expires"));
|
|
324
|
-
await
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
825
|
+
const result = await waitForPairingOrShutdown(pairingClaimed.promise);
|
|
826
|
+
await service.close();
|
|
827
|
+
if (result === "claimed") {
|
|
828
|
+
console.log(t("pair.claimed"));
|
|
829
|
+
try {
|
|
830
|
+
const autostart2 = await enableAutostart();
|
|
831
|
+
if (autostart2.supported && autostart2.enabled) {
|
|
832
|
+
console.log(t("autostart.enabled", { method: autostart2.method, path: autostart2.filePath ?? "" }));
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
836
|
+
console.log(t("pair.autostartFailed", { message }));
|
|
837
|
+
}
|
|
838
|
+
const status = await startDaemonProcess(paths);
|
|
839
|
+
console.log(t("start.backgroundStarted", { pid: status.pid ?? "unknown" }));
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
var autostart = program.command("autostart").description(helpText("autostart.description"));
|
|
843
|
+
autostart.command("on").description(helpText("autostart.on.description")).action(async () => {
|
|
844
|
+
const config = await loadConfig();
|
|
845
|
+
const language = resolveLanguage(config.language);
|
|
846
|
+
const t = translate.bind(null, language);
|
|
847
|
+
const status = await enableAutostart();
|
|
848
|
+
if (!status.supported) {
|
|
849
|
+
console.log(t("autostart.unsupported"));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
console.log(t("autostart.enabled", { method: status.method, path: status.filePath ?? "" }));
|
|
853
|
+
});
|
|
854
|
+
autostart.command("off").description(helpText("autostart.off.description")).action(async () => {
|
|
855
|
+
const config = await loadConfig();
|
|
856
|
+
const language = resolveLanguage(config.language);
|
|
857
|
+
const t = translate.bind(null, language);
|
|
858
|
+
await disableAutostart();
|
|
859
|
+
console.log(t("autostart.disabled"));
|
|
860
|
+
});
|
|
861
|
+
autostart.command("status").description(helpText("autostart.status.description")).action(async () => {
|
|
862
|
+
const config = await loadConfig();
|
|
863
|
+
const language = resolveLanguage(config.language);
|
|
864
|
+
const t = translate.bind(null, language);
|
|
865
|
+
const status = await getAutostartStatus();
|
|
866
|
+
if (!status.supported) {
|
|
867
|
+
console.log(t("autostart.unsupported"));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
console.log(
|
|
871
|
+
t(status.enabled ? "autostart.status.enabled" : "autostart.status.disabled", {
|
|
872
|
+
method: status.method,
|
|
873
|
+
path: status.filePath ?? ""
|
|
874
|
+
})
|
|
875
|
+
);
|
|
876
|
+
});
|
|
877
|
+
program.command("logs").description(helpText("logs.description")).action(async () => {
|
|
878
|
+
const paths = resolveRuntimePaths();
|
|
879
|
+
const config = await loadConfig(paths);
|
|
880
|
+
const language = resolveLanguage(config.language);
|
|
881
|
+
const t = translate.bind(null, language);
|
|
882
|
+
console.log(t("logs.servicePath", { path: getLinkLogFile(paths) }));
|
|
883
|
+
console.log(t("logs.daemonPath", { path: daemonLogFile(paths) }));
|
|
328
884
|
});
|
|
329
885
|
program.command("doctor").description(helpText("doctor.description")).action(async () => {
|
|
330
886
|
const [identity, config] = await Promise.all([ensureIdentity(), loadConfig()]);
|
|
@@ -350,10 +906,6 @@ async function loadCliLanguage() {
|
|
|
350
906
|
const config = await loadConfig();
|
|
351
907
|
return resolveLanguage(config.language);
|
|
352
908
|
}
|
|
353
|
-
async function startHttpServer(port) {
|
|
354
|
-
const app = await createApp();
|
|
355
|
-
return app.listen(port);
|
|
356
|
-
}
|
|
357
909
|
async function waitForShutdown(cleanup) {
|
|
358
910
|
await new Promise((resolve) => {
|
|
359
911
|
const stop = () => resolve();
|
|
@@ -362,4 +914,25 @@ async function waitForShutdown(cleanup) {
|
|
|
362
914
|
});
|
|
363
915
|
await cleanup();
|
|
364
916
|
}
|
|
917
|
+
async function waitForPairingOrShutdown(pairingClaimed) {
|
|
918
|
+
let stop = null;
|
|
919
|
+
const shutdown = new Promise((resolve) => {
|
|
920
|
+
stop = () => resolve("shutdown");
|
|
921
|
+
process.once("SIGINT", stop);
|
|
922
|
+
process.once("SIGTERM", stop);
|
|
923
|
+
});
|
|
924
|
+
const result = await Promise.race([pairingClaimed.then(() => "claimed"), shutdown]);
|
|
925
|
+
if (stop) {
|
|
926
|
+
process.off("SIGINT", stop);
|
|
927
|
+
process.off("SIGTERM", stop);
|
|
928
|
+
}
|
|
929
|
+
return result;
|
|
930
|
+
}
|
|
931
|
+
function createDeferred() {
|
|
932
|
+
let resolve;
|
|
933
|
+
const promise = new Promise((innerResolve) => {
|
|
934
|
+
resolve = innerResolve;
|
|
935
|
+
});
|
|
936
|
+
return { promise, resolve };
|
|
937
|
+
}
|
|
365
938
|
//# sourceMappingURL=index.js.map
|