@bulolo/hermes-link 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-C24HF73Y.js +1843 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +525 -0
- package/dist/http/app.d.ts +111 -0
- package/dist/http/app.js +6 -0
- package/package.json +69 -0
- package/scripts/check-node-version.mjs +70 -0
- package/scripts/postinstall.mjs +45 -0
|
@@ -0,0 +1,1843 @@
|
|
|
1
|
+
// src/http/app.ts
|
|
2
|
+
import Koa from "koa";
|
|
3
|
+
import bodyParser from "koa-bodyparser";
|
|
4
|
+
import cors from "@koa/cors";
|
|
5
|
+
import Router3 from "@koa/router";
|
|
6
|
+
|
|
7
|
+
// src/runtime/logger.ts
|
|
8
|
+
import { appendFile, mkdir, open, readFile, rename, rm, stat } from "fs/promises";
|
|
9
|
+
import os2 from "os";
|
|
10
|
+
import path2 from "path";
|
|
11
|
+
import pino from "pino";
|
|
12
|
+
|
|
13
|
+
// src/constants.ts
|
|
14
|
+
var LINK_COMMAND = "hermeslink";
|
|
15
|
+
var LINK_VERSION = "0.1.0";
|
|
16
|
+
var LINK_DEFAULT_PORT = 52379;
|
|
17
|
+
var LINK_RUNTIME_DIR_NAME = ".hermeslink";
|
|
18
|
+
var DEFAULT_LOG_FILE = "hermeslink.log";
|
|
19
|
+
var DAEMON_LOG_FILE = "daemon.log";
|
|
20
|
+
var GATEWAY_LOG_FILE = "hermes-gateway.log";
|
|
21
|
+
var DEFAULT_HERMES_API_SERVER_PORT = 8642;
|
|
22
|
+
var PROFILE_API_SERVER_PORT_START = DEFAULT_HERMES_API_SERVER_PORT + 1;
|
|
23
|
+
var PROFILE_API_SERVER_PORT_END = DEFAULT_HERMES_API_SERVER_PORT + 999;
|
|
24
|
+
|
|
25
|
+
// src/runtime/paths.ts
|
|
26
|
+
import os from "os";
|
|
27
|
+
import path from "path";
|
|
28
|
+
function resolveRuntimeHome() {
|
|
29
|
+
return process.env.HERMESLINK_HOME?.trim() ? path.resolve(process.env.HERMESLINK_HOME) : path.join(os.homedir(), LINK_RUNTIME_DIR_NAME);
|
|
30
|
+
}
|
|
31
|
+
function resolveRuntimePaths(homeDir = resolveRuntimeHome()) {
|
|
32
|
+
return {
|
|
33
|
+
homeDir,
|
|
34
|
+
identityFile: path.join(homeDir, "identity.json"),
|
|
35
|
+
configFile: path.join(homeDir, "config.json"),
|
|
36
|
+
stateFile: path.join(homeDir, "state.json"),
|
|
37
|
+
credentialsFile: path.join(homeDir, "credentials.json"),
|
|
38
|
+
databaseFile: path.join(homeDir, "link.db"),
|
|
39
|
+
conversationsDir: path.join(homeDir, "conversations"),
|
|
40
|
+
blobsDir: path.join(homeDir, "blobs"),
|
|
41
|
+
indexesDir: path.join(homeDir, "indexes"),
|
|
42
|
+
logsDir: path.join(homeDir, "logs"),
|
|
43
|
+
runDir: path.join(homeDir, "run"),
|
|
44
|
+
pairingDir: path.join(homeDir, "pairing")
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/runtime/logger.ts
|
|
49
|
+
var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
|
|
50
|
+
var DEFAULT_MAX_FILES = 5;
|
|
51
|
+
var DEFAULT_READ_LIMIT = 200;
|
|
52
|
+
var MAX_READ_LIMIT = 1e3;
|
|
53
|
+
var DEFAULT_MAX_BYTES_PER_FILE = 512 * 1024;
|
|
54
|
+
var LOG_LEVEL_PRIORITY = {
|
|
55
|
+
debug: 10,
|
|
56
|
+
info: 20,
|
|
57
|
+
warn: 30,
|
|
58
|
+
error: 40
|
|
59
|
+
};
|
|
60
|
+
var FileLogger = class {
|
|
61
|
+
filePath;
|
|
62
|
+
paths;
|
|
63
|
+
maxFileBytes;
|
|
64
|
+
maxFiles;
|
|
65
|
+
minLevel;
|
|
66
|
+
now;
|
|
67
|
+
queue = Promise.resolve();
|
|
68
|
+
constructor(options = {}) {
|
|
69
|
+
this.paths = options.paths ?? resolveRuntimePaths();
|
|
70
|
+
this.filePath = getLinkLogFile(this.paths, options.fileName);
|
|
71
|
+
this.maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
|
|
72
|
+
this.maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
73
|
+
this.minLevel = options.minLevel ?? "warn";
|
|
74
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
75
|
+
}
|
|
76
|
+
debug(message, fields) {
|
|
77
|
+
return this.write("debug", message, fields);
|
|
78
|
+
}
|
|
79
|
+
info(message, fields) {
|
|
80
|
+
return this.write("info", message, fields);
|
|
81
|
+
}
|
|
82
|
+
warn(message, fields) {
|
|
83
|
+
return this.write("warn", message, fields);
|
|
84
|
+
}
|
|
85
|
+
error(message, fields) {
|
|
86
|
+
return this.write("error", message, fields);
|
|
87
|
+
}
|
|
88
|
+
write(level, message, fields) {
|
|
89
|
+
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[this.minLevel]) {
|
|
90
|
+
return Promise.resolve();
|
|
91
|
+
}
|
|
92
|
+
const entry = {
|
|
93
|
+
ts: this.now().toISOString(),
|
|
94
|
+
level,
|
|
95
|
+
message,
|
|
96
|
+
...fields ? { fields: sanitizeFields(fields) } : {}
|
|
97
|
+
};
|
|
98
|
+
const next = this.queue.then(() => this.appendEntry(entry)).catch(() => void 0);
|
|
99
|
+
this.queue = next;
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
flush() {
|
|
103
|
+
return this.queue;
|
|
104
|
+
}
|
|
105
|
+
async appendEntry(entry) {
|
|
106
|
+
await mkdir(this.paths.logsDir, { recursive: true, mode: 448 });
|
|
107
|
+
const line = `${JSON.stringify(entry)}
|
|
108
|
+
`;
|
|
109
|
+
await rotateLogFileIfNeeded(this.filePath, Buffer.byteLength(line, "utf8"), this.maxFileBytes, this.maxFiles);
|
|
110
|
+
await appendFile(this.filePath, line, { mode: 384 });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
function createFileLogger(options = {}) {
|
|
114
|
+
return new FileLogger(options);
|
|
115
|
+
}
|
|
116
|
+
function getLinkLogFile(paths = resolveRuntimePaths(), fileName = DEFAULT_LOG_FILE) {
|
|
117
|
+
return path2.join(paths.logsDir, fileName);
|
|
118
|
+
}
|
|
119
|
+
function getGatewayRuntimeLogFile(paths = resolveRuntimePaths()) {
|
|
120
|
+
return getLinkLogFile(paths, GATEWAY_LOG_FILE);
|
|
121
|
+
}
|
|
122
|
+
function getGatewayLogFiles(paths = resolveRuntimePaths()) {
|
|
123
|
+
const runtimeGatewayLog = getGatewayRuntimeLogFile(paths);
|
|
124
|
+
const effectiveHome = path2.basename(paths.homeDir) === ".hermeslink" ? path2.dirname(paths.homeDir) : os2.homedir();
|
|
125
|
+
const hermesGatewayErrorLog = path2.join(effectiveHome, ".hermes", "logs", "gateway.error.log");
|
|
126
|
+
return Array.from(/* @__PURE__ */ new Set([runtimeGatewayLog, hermesGatewayErrorLog]));
|
|
127
|
+
}
|
|
128
|
+
function createRotatingTextLogWriter(options) {
|
|
129
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
130
|
+
const filePath = getLinkLogFile(paths, options.fileName);
|
|
131
|
+
const maxFileBytes = Math.max(256, Math.floor(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES));
|
|
132
|
+
const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
133
|
+
let queue = Promise.resolve();
|
|
134
|
+
return {
|
|
135
|
+
filePath,
|
|
136
|
+
write(chunk) {
|
|
137
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
|
|
138
|
+
if (buffer.length === 0) {
|
|
139
|
+
return queue;
|
|
140
|
+
}
|
|
141
|
+
const next = queue.then(async () => {
|
|
142
|
+
await mkdir(paths.logsDir, { recursive: true, mode: 448 });
|
|
143
|
+
await rotateLogFileIfNeeded(filePath, buffer.length, maxFileBytes, maxFiles);
|
|
144
|
+
await appendFile(filePath, buffer, { mode: 384 });
|
|
145
|
+
}).catch(() => void 0);
|
|
146
|
+
queue = next;
|
|
147
|
+
return next;
|
|
148
|
+
},
|
|
149
|
+
flush() {
|
|
150
|
+
return queue;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async function readRecentLogEntries(options = {}) {
|
|
155
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
156
|
+
const filePath = getLinkLogFile(paths, options.fileName);
|
|
157
|
+
const limit = clampLimit(options.limit);
|
|
158
|
+
const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
159
|
+
const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
|
|
160
|
+
const files = [
|
|
161
|
+
filePath,
|
|
162
|
+
...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))
|
|
163
|
+
];
|
|
164
|
+
const entries = [];
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const raw = await readTail(file, maxBytesPerFile);
|
|
167
|
+
if (!raw) continue;
|
|
168
|
+
const lines = raw.split(/\r?\n/u).filter(Boolean);
|
|
169
|
+
for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
|
|
170
|
+
const entry = parseLogLine(lines[index]);
|
|
171
|
+
if (entry) entries.push(entry);
|
|
172
|
+
}
|
|
173
|
+
if (entries.length >= limit) break;
|
|
174
|
+
}
|
|
175
|
+
return entries.reverse();
|
|
176
|
+
}
|
|
177
|
+
async function readRecentTextLogEntries(options = {}) {
|
|
178
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
179
|
+
const primaryFiles = options.filePaths ?? [getLinkLogFile(paths, options.fileName)];
|
|
180
|
+
const limit = clampLimit(options.limit);
|
|
181
|
+
const maxFiles = Math.max(0, Math.floor(options.maxFiles ?? DEFAULT_MAX_FILES));
|
|
182
|
+
const maxBytesPerFile = Math.max(1024, Math.floor(options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE));
|
|
183
|
+
const files = primaryFiles.flatMap((filePath) => [
|
|
184
|
+
filePath,
|
|
185
|
+
...Array.from({ length: maxFiles }, (_, index) => rotatedLogFile(filePath, index + 1))
|
|
186
|
+
]);
|
|
187
|
+
const entries = [];
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const tail = await readTailWithMetadata(file, maxBytesPerFile);
|
|
190
|
+
if (!tail) continue;
|
|
191
|
+
const lines = tail.content.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
|
|
192
|
+
for (let index = lines.length - 1; index >= 0 && entries.length < limit; index -= 1) {
|
|
193
|
+
entries.push(parseTextLogLine(lines[index], tail.modifiedAt));
|
|
194
|
+
}
|
|
195
|
+
if (entries.length >= limit) break;
|
|
196
|
+
}
|
|
197
|
+
return entries.reverse();
|
|
198
|
+
}
|
|
199
|
+
function readRecentGatewayLogEntries(options = {}) {
|
|
200
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
201
|
+
return readRecentTextLogEntries({
|
|
202
|
+
...options,
|
|
203
|
+
paths,
|
|
204
|
+
filePaths: options.filePaths ?? getGatewayLogFiles(paths)
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function createLogger(options) {
|
|
208
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
209
|
+
const logFile = getLinkLogFile(paths, options.fileName);
|
|
210
|
+
return pino({ level: options.level ?? "warn" }, pino.destination({ dest: logFile, sync: false, mkdir: true }));
|
|
211
|
+
}
|
|
212
|
+
function clampLimit(value) {
|
|
213
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
214
|
+
return DEFAULT_READ_LIMIT;
|
|
215
|
+
}
|
|
216
|
+
return Math.min(MAX_READ_LIMIT, Math.max(1, Math.floor(value)));
|
|
217
|
+
}
|
|
218
|
+
async function rotateLogFileIfNeeded(filePath, nextBytes, maxFileBytes, maxFiles) {
|
|
219
|
+
const current = await stat(filePath).catch(() => null);
|
|
220
|
+
if (!current || current.size === 0 || current.size + nextBytes <= maxFileBytes) return;
|
|
221
|
+
if (maxFiles === 0) {
|
|
222
|
+
await rm(filePath, { force: true }).catch(() => void 0);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await rm(rotatedLogFile(filePath, maxFiles), { force: true }).catch(() => void 0);
|
|
226
|
+
for (let index = maxFiles - 1; index >= 1; index -= 1) {
|
|
227
|
+
await moveIfExists(rotatedLogFile(filePath, index), rotatedLogFile(filePath, index + 1));
|
|
228
|
+
}
|
|
229
|
+
await moveIfExists(filePath, rotatedLogFile(filePath, 1));
|
|
230
|
+
}
|
|
231
|
+
function sanitizeFields(fields) {
|
|
232
|
+
return sanitizeObject(fields, 0);
|
|
233
|
+
}
|
|
234
|
+
function sanitizeValue(value, depth) {
|
|
235
|
+
if (value === null || typeof value === "boolean") return value;
|
|
236
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
237
|
+
if (typeof value === "string") return value.length > 2e3 ? `${value.slice(0, 2e3)}...` : value;
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
if (depth >= 3) return "[array]";
|
|
240
|
+
return value.slice(0, 20).map((item) => sanitizeValue(item, depth + 1));
|
|
241
|
+
}
|
|
242
|
+
if (typeof value === "object" && value !== null) {
|
|
243
|
+
if (depth >= 3) return "[object]";
|
|
244
|
+
return sanitizeObject(value, depth + 1);
|
|
245
|
+
}
|
|
246
|
+
return String(value);
|
|
247
|
+
}
|
|
248
|
+
function sanitizeObject(value, depth) {
|
|
249
|
+
const result = {};
|
|
250
|
+
for (const [key, child] of Object.entries(value).slice(0, 50)) {
|
|
251
|
+
if (isSensitiveKey(key)) {
|
|
252
|
+
result[key] = "[redacted]";
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
result[key] = sanitizeValue(child, depth);
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
function isSensitiveKey(key) {
|
|
260
|
+
return /(authorization|cookie|token|secret|password|private[_-]?key|api[_-]?key)/iu.test(key);
|
|
261
|
+
}
|
|
262
|
+
function parseLogLine(line) {
|
|
263
|
+
try {
|
|
264
|
+
const value = JSON.parse(line);
|
|
265
|
+
if (!value || typeof value.ts !== "string" || !isLogLevel(value.level) || typeof value.message !== "string") {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
ts: value.ts,
|
|
270
|
+
level: value.level,
|
|
271
|
+
message: value.message,
|
|
272
|
+
timestampSource: "structured",
|
|
273
|
+
...value.fields && typeof value.fields === "object" ? { fields: value.fields } : {}
|
|
274
|
+
};
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function parseTextLogLine(line, fallbackTimestamp) {
|
|
280
|
+
const embeddedTimestamp = readTimestampFromTextLog(line);
|
|
281
|
+
return {
|
|
282
|
+
ts: embeddedTimestamp ?? fallbackTimestamp ?? null,
|
|
283
|
+
level: inferTextLogLevel(line),
|
|
284
|
+
message: line,
|
|
285
|
+
...embeddedTimestamp ? { timestampSource: "embedded" } : fallbackTimestamp ? { timestampSource: "file_mtime" } : {}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function readTimestampFromTextLog(line) {
|
|
289
|
+
const iso = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/u.exec(line);
|
|
290
|
+
if (iso) return iso[0];
|
|
291
|
+
const bracketed = /^\[?(?<value>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]?/u.exec(line);
|
|
292
|
+
if (!bracketed?.groups?.value) return null;
|
|
293
|
+
const parsed = new Date(bracketed.groups.value.replace(" ", "T"));
|
|
294
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
295
|
+
}
|
|
296
|
+
function inferTextLogLevel(line) {
|
|
297
|
+
if (/\b(error|fatal|traceback|failed|failure)\b/iu.test(line)) return "error";
|
|
298
|
+
if (/\b(warn|warning)\b/iu.test(line)) return "warn";
|
|
299
|
+
if (/\b(debug|trace)\b/iu.test(line)) return "debug";
|
|
300
|
+
return "info";
|
|
301
|
+
}
|
|
302
|
+
function isLogLevel(value) {
|
|
303
|
+
return value === "debug" || value === "info" || value === "warn" || value === "error";
|
|
304
|
+
}
|
|
305
|
+
async function readTail(filePath, maxBytes) {
|
|
306
|
+
const tail = await readTailWithMetadata(filePath, maxBytes);
|
|
307
|
+
return tail?.content ?? null;
|
|
308
|
+
}
|
|
309
|
+
async function readTailWithMetadata(filePath, maxBytes) {
|
|
310
|
+
const info = await stat(filePath).catch(() => null);
|
|
311
|
+
if (!info || info.size <= 0) return null;
|
|
312
|
+
const modifiedAt = info.mtime.toISOString();
|
|
313
|
+
if (info.size <= maxBytes) {
|
|
314
|
+
const content = await readFile(filePath, "utf8").catch(() => null);
|
|
315
|
+
return content === null ? null : { content, modifiedAt };
|
|
316
|
+
}
|
|
317
|
+
const handle = await open(filePath, "r").catch(() => null);
|
|
318
|
+
if (!handle) return null;
|
|
319
|
+
try {
|
|
320
|
+
const length = Math.min(info.size, maxBytes);
|
|
321
|
+
const buffer = Buffer.alloc(length);
|
|
322
|
+
await handle.read(buffer, 0, length, info.size - length);
|
|
323
|
+
return { content: buffer.toString("utf8"), modifiedAt };
|
|
324
|
+
} finally {
|
|
325
|
+
await handle.close();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function moveIfExists(from, to) {
|
|
329
|
+
await rm(to, { force: true }).catch(() => void 0);
|
|
330
|
+
await rename(from, to).catch((error) => {
|
|
331
|
+
if (error.code !== "ENOENT") throw error;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function rotatedLogFile(filePath, index) {
|
|
335
|
+
return `${filePath}.${index}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/http/routes/system.ts
|
|
339
|
+
import Router from "@koa/router";
|
|
340
|
+
|
|
341
|
+
// src/autostart/autostart.ts
|
|
342
|
+
import { execFile } from "child_process";
|
|
343
|
+
import { mkdir as mkdir2, readFile as readFile2, rm as rm2, writeFile } from "fs/promises";
|
|
344
|
+
import os3 from "os";
|
|
345
|
+
import path3 from "path";
|
|
346
|
+
import { promisify } from "util";
|
|
347
|
+
var execFileAsync = promisify(execFile);
|
|
348
|
+
var MACOS_LABEL = "com.hermes.link";
|
|
349
|
+
async function enableAutostart() {
|
|
350
|
+
const definition = await resolveAutostartDefinition();
|
|
351
|
+
if (!definition) {
|
|
352
|
+
return unsupportedStatus();
|
|
353
|
+
}
|
|
354
|
+
await mkdir2(path3.dirname(definition.filePath), { recursive: true, mode: 448 });
|
|
355
|
+
await writeFile(definition.filePath, definition.content, { mode: 384 });
|
|
356
|
+
if (definition.method === "systemd-user") {
|
|
357
|
+
await execFileAsync("systemctl", ["--user", "enable", path3.basename(definition.filePath)]).catch(async () => {
|
|
358
|
+
await rm2(definition.filePath, { force: true }).catch(() => void 0);
|
|
359
|
+
const fallback = xdgAutostartDefinition();
|
|
360
|
+
await mkdir2(path3.dirname(fallback.filePath), { recursive: true, mode: 448 });
|
|
361
|
+
await writeFile(fallback.filePath, fallback.content, { mode: 384 });
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return getAutostartStatus();
|
|
365
|
+
}
|
|
366
|
+
async function disableAutostart() {
|
|
367
|
+
const definitions = await allAutostartDefinitions();
|
|
368
|
+
for (const definition of definitions) {
|
|
369
|
+
if (definition.method === "systemd-user") {
|
|
370
|
+
await execFileAsync("systemctl", ["--user", "disable", path3.basename(definition.filePath)]).catch(() => void 0);
|
|
371
|
+
}
|
|
372
|
+
await rm2(definition.filePath, { force: true }).catch(() => void 0);
|
|
373
|
+
}
|
|
374
|
+
return getAutostartStatus();
|
|
375
|
+
}
|
|
376
|
+
async function getAutostartStatus() {
|
|
377
|
+
const definitions = await allAutostartDefinitions();
|
|
378
|
+
if (definitions.length === 0) {
|
|
379
|
+
return unsupportedStatus();
|
|
380
|
+
}
|
|
381
|
+
for (const definition of definitions) {
|
|
382
|
+
const content = await readFile2(definition.filePath, "utf8").catch(() => null);
|
|
383
|
+
if (content !== null) {
|
|
384
|
+
return {
|
|
385
|
+
supported: true,
|
|
386
|
+
enabled: true,
|
|
387
|
+
method: definition.method,
|
|
388
|
+
filePath: definition.filePath
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const primary = definitions[0];
|
|
393
|
+
return {
|
|
394
|
+
supported: true,
|
|
395
|
+
enabled: false,
|
|
396
|
+
method: primary.method,
|
|
397
|
+
filePath: primary.filePath
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
async function resolveAutostartDefinition() {
|
|
401
|
+
if (process.platform === "darwin") {
|
|
402
|
+
return launchdDefinition();
|
|
403
|
+
}
|
|
404
|
+
if (process.platform === "win32") {
|
|
405
|
+
return windowsStartupDefinition();
|
|
406
|
+
}
|
|
407
|
+
if (process.platform === "linux") {
|
|
408
|
+
return await hasSystemctlUser() ? systemdUserDefinition() : xdgAutostartDefinition();
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
async function allAutostartDefinitions() {
|
|
413
|
+
if (process.platform === "darwin") {
|
|
414
|
+
return [launchdDefinition()];
|
|
415
|
+
}
|
|
416
|
+
if (process.platform === "win32") {
|
|
417
|
+
return [windowsStartupDefinition()];
|
|
418
|
+
}
|
|
419
|
+
if (process.platform === "linux") {
|
|
420
|
+
return [systemdUserDefinition(), xdgAutostartDefinition()];
|
|
421
|
+
}
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
async function hasSystemctlUser() {
|
|
425
|
+
try {
|
|
426
|
+
await execFileAsync("systemctl", ["--user", "show-environment"]);
|
|
427
|
+
return true;
|
|
428
|
+
} catch {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function launchdDefinition() {
|
|
433
|
+
const filePath = path3.join(os3.homedir(), "Library", "LaunchAgents", `${MACOS_LABEL}.plist`);
|
|
434
|
+
return {
|
|
435
|
+
method: "launchd",
|
|
436
|
+
filePath,
|
|
437
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
438
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
439
|
+
<plist version="1.0">
|
|
440
|
+
<dict>
|
|
441
|
+
<key>Label</key>
|
|
442
|
+
<string>${MACOS_LABEL}</string>
|
|
443
|
+
<key>ProgramArguments</key>
|
|
444
|
+
<array>
|
|
445
|
+
<string>${xmlEscape(process.execPath)}</string>
|
|
446
|
+
<string>${xmlEscape(currentCliScriptPath())}</string>
|
|
447
|
+
<string>daemon-supervisor</string>
|
|
448
|
+
</array>
|
|
449
|
+
<key>RunAtLoad</key>
|
|
450
|
+
<true/>
|
|
451
|
+
<key>KeepAlive</key>
|
|
452
|
+
<false/>
|
|
453
|
+
</dict>
|
|
454
|
+
</plist>
|
|
455
|
+
`
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function systemdUserDefinition() {
|
|
459
|
+
const filePath = path3.join(os3.homedir(), ".config", "systemd", "user", "hermeslink.service");
|
|
460
|
+
return {
|
|
461
|
+
method: "systemd-user",
|
|
462
|
+
filePath,
|
|
463
|
+
content: `[Unit]
|
|
464
|
+
Description=Hermes Link
|
|
465
|
+
After=network-online.target
|
|
466
|
+
|
|
467
|
+
[Service]
|
|
468
|
+
Type=simple
|
|
469
|
+
ExecStart=${systemdQuote(process.execPath)} ${systemdQuote(currentCliScriptPath())} daemon-supervisor
|
|
470
|
+
Restart=no
|
|
471
|
+
|
|
472
|
+
[Install]
|
|
473
|
+
WantedBy=default.target
|
|
474
|
+
`
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function xdgAutostartDefinition() {
|
|
478
|
+
const filePath = path3.join(os3.homedir(), ".config", "autostart", "hermeslink.desktop");
|
|
479
|
+
return {
|
|
480
|
+
method: "xdg-autostart",
|
|
481
|
+
filePath,
|
|
482
|
+
content: `[Desktop Entry]
|
|
483
|
+
Type=Application
|
|
484
|
+
Name=Hermes Link
|
|
485
|
+
Exec=${desktopQuote(process.execPath)} ${desktopQuote(currentCliScriptPath())} daemon-supervisor
|
|
486
|
+
Terminal=false
|
|
487
|
+
X-GNOME-Autostart-enabled=true
|
|
488
|
+
`
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
function windowsStartupDefinition() {
|
|
492
|
+
const appData = process.env.APPDATA ?? path3.join(os3.homedir(), "AppData", "Roaming");
|
|
493
|
+
const filePath = path3.join(
|
|
494
|
+
appData,
|
|
495
|
+
"Microsoft",
|
|
496
|
+
"Windows",
|
|
497
|
+
"Start Menu",
|
|
498
|
+
"Programs",
|
|
499
|
+
"Startup",
|
|
500
|
+
"HermesLink.cmd"
|
|
501
|
+
);
|
|
502
|
+
return {
|
|
503
|
+
method: "windows-startup",
|
|
504
|
+
filePath,
|
|
505
|
+
content: `@echo off\r
|
|
506
|
+
start "" /min "${process.execPath}" "${currentCliScriptPath()}" daemon-supervisor\r
|
|
507
|
+
`
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function unsupportedStatus() {
|
|
511
|
+
return { supported: false, enabled: false, method: "unsupported", filePath: null };
|
|
512
|
+
}
|
|
513
|
+
function xmlEscape(value) {
|
|
514
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
515
|
+
}
|
|
516
|
+
function systemdQuote(value) {
|
|
517
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
518
|
+
}
|
|
519
|
+
function desktopQuote(value) {
|
|
520
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
521
|
+
}
|
|
522
|
+
function currentCliScriptPath() {
|
|
523
|
+
return process.argv[1];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/network/environment.ts
|
|
527
|
+
import { existsSync, readFileSync } from "fs";
|
|
528
|
+
import os4 from "os";
|
|
529
|
+
function detectRuntimeEnvironment(env = process.env) {
|
|
530
|
+
if (isWsl(env)) {
|
|
531
|
+
return {
|
|
532
|
+
kind: "wsl",
|
|
533
|
+
lanAutoDiscoveryUsable: false,
|
|
534
|
+
warning: "Detected WSL. The LAN IP found inside WSL is usually a private VM address and is not reachable from your phone. Use Relay or set `hermeslink config set lan-host <Windows LAN IP>`."
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (isContainer(env)) {
|
|
538
|
+
return {
|
|
539
|
+
kind: "container",
|
|
540
|
+
lanAutoDiscoveryUsable: false,
|
|
541
|
+
warning: "Detected a container environment. Container LAN IPs are usually not reachable from your phone. Use Relay or set `hermeslink config set lan-host <host LAN IP>`."
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
return { kind: "native", lanAutoDiscoveryUsable: true, warning: null };
|
|
545
|
+
}
|
|
546
|
+
function isWsl(env) {
|
|
547
|
+
if (process.platform !== "linux") {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
const release = os4.release().toLowerCase();
|
|
554
|
+
return release.includes("microsoft") || release.includes("wsl");
|
|
555
|
+
}
|
|
556
|
+
function isContainer(env) {
|
|
557
|
+
if (env.container || env.CONTAINER || env.KUBERNETES_SERVICE_HOST) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (existsSync("/.dockerenv")) {
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const cgroup = readFileSync("/proc/1/cgroup", "utf8").toLowerCase();
|
|
565
|
+
return /docker|containerd|kubepods|libpod|podman/u.test(cgroup);
|
|
566
|
+
} catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// src/storage/atomic-json.ts
|
|
572
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
573
|
+
|
|
574
|
+
// src/storage/atomic-file.ts
|
|
575
|
+
import { randomUUID } from "crypto";
|
|
576
|
+
import {
|
|
577
|
+
chmod,
|
|
578
|
+
chown,
|
|
579
|
+
lstat,
|
|
580
|
+
mkdir as mkdir3,
|
|
581
|
+
open as open2,
|
|
582
|
+
readdir,
|
|
583
|
+
rename as rename2,
|
|
584
|
+
rm as rm3,
|
|
585
|
+
stat as stat2
|
|
586
|
+
} from "fs/promises";
|
|
587
|
+
import path4 from "path";
|
|
588
|
+
async function atomicWriteFilePreservingMetadata(filePath, value, options = {}) {
|
|
589
|
+
const resolvedPath = path4.resolve(filePath);
|
|
590
|
+
const directory = path4.dirname(resolvedPath);
|
|
591
|
+
await ensureDirectoryWithInheritedMetadata(directory, options.directoryMode ?? 448);
|
|
592
|
+
const existingMetadata = await readExistingFileMetadata(resolvedPath) ?? (options.metadataSourcePath ? await readExistingFileMetadata(path4.resolve(options.metadataSourcePath)) : null);
|
|
593
|
+
const directoryMetadata = await readPathMetadata(directory);
|
|
594
|
+
const metadata = {
|
|
595
|
+
uid: existingMetadata?.uid ?? directoryMetadata.uid,
|
|
596
|
+
gid: existingMetadata?.gid ?? directoryMetadata.gid,
|
|
597
|
+
mode: existingMetadata?.mode ?? options.mode ?? 384
|
|
598
|
+
};
|
|
599
|
+
const tempPath = path4.join(
|
|
600
|
+
directory,
|
|
601
|
+
`.${path4.basename(resolvedPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`
|
|
602
|
+
);
|
|
603
|
+
try {
|
|
604
|
+
const handle = await open2(tempPath, "wx", metadata.mode);
|
|
605
|
+
try {
|
|
606
|
+
if (typeof value === "string") {
|
|
607
|
+
await handle.writeFile(value, options.encoding ?? "utf8");
|
|
608
|
+
} else {
|
|
609
|
+
await handle.writeFile(value);
|
|
610
|
+
}
|
|
611
|
+
await handle.sync();
|
|
612
|
+
} finally {
|
|
613
|
+
await handle.close();
|
|
614
|
+
}
|
|
615
|
+
await applyMetadata(tempPath, metadata);
|
|
616
|
+
await rename2(tempPath, resolvedPath);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
await rm3(tempPath, { force: true });
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
async function ensureDirectoryWithInheritedMetadata(directory, mode) {
|
|
623
|
+
const { source, missing } = await findExistingAncestor(directory);
|
|
624
|
+
await mkdir3(directory, { recursive: true, mode });
|
|
625
|
+
for (const missingDirectory of missing) {
|
|
626
|
+
await applyMetadata(missingDirectory, { uid: source.uid, gid: source.gid, mode });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function findExistingAncestor(directory) {
|
|
630
|
+
const missing = [];
|
|
631
|
+
let current = path4.resolve(directory);
|
|
632
|
+
while (true) {
|
|
633
|
+
const currentStat = await stat2(current).catch((error) => {
|
|
634
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
635
|
+
throw error;
|
|
636
|
+
});
|
|
637
|
+
if (currentStat) {
|
|
638
|
+
if (!currentStat.isDirectory()) throw new Error(`${current} is not a directory`);
|
|
639
|
+
return { source: metadataFromStats(currentStat), missing: missing.reverse() };
|
|
640
|
+
}
|
|
641
|
+
missing.push(current);
|
|
642
|
+
const parent = path4.dirname(current);
|
|
643
|
+
if (parent === current) throw new Error(`No existing parent directory for ${directory}`);
|
|
644
|
+
current = parent;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function readExistingFileMetadata(filePath) {
|
|
648
|
+
const fileStat = await stat2(filePath).catch((error) => {
|
|
649
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
650
|
+
throw error;
|
|
651
|
+
});
|
|
652
|
+
if (!fileStat) return null;
|
|
653
|
+
if (!fileStat.isFile()) throw new Error(`${filePath} is not a file`);
|
|
654
|
+
return metadataFromStats(fileStat);
|
|
655
|
+
}
|
|
656
|
+
async function readPathMetadata(filePath) {
|
|
657
|
+
return metadataFromStats(await stat2(filePath));
|
|
658
|
+
}
|
|
659
|
+
async function applyMetadata(filePath, metadata) {
|
|
660
|
+
await applyOwner(filePath, metadata);
|
|
661
|
+
await chmod(filePath, metadata.mode);
|
|
662
|
+
}
|
|
663
|
+
async function applyOwner(filePath, metadata) {
|
|
664
|
+
if (process.platform === "win32") return;
|
|
665
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : void 0;
|
|
666
|
+
const currentGid = typeof process.getgid === "function" ? process.getgid() : void 0;
|
|
667
|
+
if (metadata.uid === currentUid && metadata.gid === currentGid) return;
|
|
668
|
+
try {
|
|
669
|
+
await chown(filePath, metadata.uid, metadata.gid);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
const current = await stat2(filePath);
|
|
672
|
+
if (current.uid !== metadata.uid || current.gid !== metadata.gid) throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function metadataFromStats(statsValue) {
|
|
676
|
+
return { uid: statsValue.uid, gid: statsValue.gid, mode: statsValue.mode & 511 };
|
|
677
|
+
}
|
|
678
|
+
function isNodeError(error, code) {
|
|
679
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/storage/atomic-json.ts
|
|
683
|
+
async function readJsonFile(filePath) {
|
|
684
|
+
try {
|
|
685
|
+
const raw = await readFile3(filePath, "utf8");
|
|
686
|
+
return JSON.parse(raw);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (isNodeError(error, "ENOENT")) return null;
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async function writeJsonFile(filePath, value, mode = 384) {
|
|
693
|
+
const payload = `${JSON.stringify(value, null, 2)}
|
|
694
|
+
`;
|
|
695
|
+
await atomicWriteFilePreservingMetadata(filePath, payload, { mode });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/link/state.ts
|
|
699
|
+
import path5 from "path";
|
|
700
|
+
var STATE_FILE = "link-state.json";
|
|
701
|
+
function stateFilePath(paths) {
|
|
702
|
+
return path5.join(paths.homeDir, STATE_FILE);
|
|
703
|
+
}
|
|
704
|
+
function defaultLinkState() {
|
|
705
|
+
return {
|
|
706
|
+
networkReport: {
|
|
707
|
+
lastReportedAt: null,
|
|
708
|
+
preferredUrls: [],
|
|
709
|
+
lanIps: [],
|
|
710
|
+
publicIpv4s: [],
|
|
711
|
+
publicIpv6s: []
|
|
712
|
+
},
|
|
713
|
+
updateAvailable: null,
|
|
714
|
+
updateDismissedAt: null
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
async function readLinkState(paths) {
|
|
718
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
719
|
+
const raw = await readJsonFile(stateFilePath(runtimePaths));
|
|
720
|
+
if (!raw || typeof raw !== "object") return defaultLinkState();
|
|
721
|
+
const state = raw;
|
|
722
|
+
return {
|
|
723
|
+
networkReport: readNetworkReportState(state.networkReport),
|
|
724
|
+
updateAvailable: typeof state.updateAvailable === "string" ? state.updateAvailable : null,
|
|
725
|
+
updateDismissedAt: typeof state.updateDismissedAt === "string" ? state.updateDismissedAt : null
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function readNetworkReportState(value) {
|
|
729
|
+
if (!value || typeof value !== "object") {
|
|
730
|
+
return defaultLinkState().networkReport;
|
|
731
|
+
}
|
|
732
|
+
const v = value;
|
|
733
|
+
return {
|
|
734
|
+
lastReportedAt: typeof v.lastReportedAt === "string" ? v.lastReportedAt : null,
|
|
735
|
+
preferredUrls: readStringArray(v.preferredUrls),
|
|
736
|
+
lanIps: readStringArray(v.lanIps),
|
|
737
|
+
publicIpv4s: readStringArray(v.publicIpv4s),
|
|
738
|
+
publicIpv6s: readStringArray(v.publicIpv6s)
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function readStringArray(value) {
|
|
742
|
+
if (!Array.isArray(value)) return [];
|
|
743
|
+
return value.filter((v) => typeof v === "string");
|
|
744
|
+
}
|
|
745
|
+
async function updateNetworkReportState(update, paths) {
|
|
746
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
747
|
+
const current = await readLinkState(runtimePaths);
|
|
748
|
+
const next = {
|
|
749
|
+
...current,
|
|
750
|
+
networkReport: { ...current.networkReport, ...update }
|
|
751
|
+
};
|
|
752
|
+
await writeJsonFile(stateFilePath(runtimePaths), next);
|
|
753
|
+
return next;
|
|
754
|
+
}
|
|
755
|
+
async function updateLinkState(update, paths) {
|
|
756
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
757
|
+
const current = await readLinkState(runtimePaths);
|
|
758
|
+
const next = { ...current, ...update };
|
|
759
|
+
await writeJsonFile(stateFilePath(runtimePaths), next);
|
|
760
|
+
return next;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/link/updates.ts
|
|
764
|
+
async function checkForUpdates(options) {
|
|
765
|
+
const paths = options.paths ?? resolveRuntimePaths();
|
|
766
|
+
const state = await readLinkState(paths);
|
|
767
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
768
|
+
try {
|
|
769
|
+
const response = await fetcher(
|
|
770
|
+
`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/link-versions/latest`,
|
|
771
|
+
{ signal: AbortSignal.timeout(5e3) }
|
|
772
|
+
);
|
|
773
|
+
if (!response.ok) {
|
|
774
|
+
return buildUpdateInfo(state.updateAvailable, state.updateDismissedAt);
|
|
775
|
+
}
|
|
776
|
+
const body = await response.json().catch(() => null);
|
|
777
|
+
const latestVersion = typeof body?.version === "string" ? body.version : null;
|
|
778
|
+
if (latestVersion && latestVersion !== LINK_VERSION) {
|
|
779
|
+
await updateLinkState({ updateAvailable: latestVersion }, paths);
|
|
780
|
+
return buildUpdateInfo(latestVersion, state.updateDismissedAt);
|
|
781
|
+
}
|
|
782
|
+
return buildUpdateInfo(latestVersion, state.updateDismissedAt);
|
|
783
|
+
} catch {
|
|
784
|
+
return buildUpdateInfo(state.updateAvailable, state.updateDismissedAt);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function buildUpdateInfo(availableVersion, dismissedAt) {
|
|
788
|
+
return {
|
|
789
|
+
currentVersion: LINK_VERSION,
|
|
790
|
+
availableVersion,
|
|
791
|
+
dismissed: dismissedAt !== null
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
async function dismissUpdate(paths) {
|
|
795
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
796
|
+
await updateLinkState({ updateDismissedAt: (/* @__PURE__ */ new Date()).toISOString() }, runtimePaths);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/security/devices.ts
|
|
800
|
+
import path6 from "path";
|
|
801
|
+
var DEVICES_FILE = "trusted-devices.json";
|
|
802
|
+
function devicesFilePath(paths) {
|
|
803
|
+
return path6.join(paths.homeDir, DEVICES_FILE);
|
|
804
|
+
}
|
|
805
|
+
async function readTrustedDevices(paths) {
|
|
806
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
807
|
+
const raw = await readJsonFile(devicesFilePath(runtimePaths));
|
|
808
|
+
if (!raw || typeof raw !== "object") return [];
|
|
809
|
+
const reg = raw;
|
|
810
|
+
if (!Array.isArray(reg.devices)) return [];
|
|
811
|
+
return reg.devices.filter(isValidDevice);
|
|
812
|
+
}
|
|
813
|
+
function isValidDevice(value) {
|
|
814
|
+
if (!value || typeof value !== "object") return false;
|
|
815
|
+
const d = value;
|
|
816
|
+
return typeof d.deviceId === "string" && typeof d.addedAt === "string";
|
|
817
|
+
}
|
|
818
|
+
async function isDeviceTrusted(deviceId, paths) {
|
|
819
|
+
const devices = await readTrustedDevices(paths);
|
|
820
|
+
return devices.some((d) => d.deviceId === deviceId);
|
|
821
|
+
}
|
|
822
|
+
async function recordDeviceSeen(deviceId, paths) {
|
|
823
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
824
|
+
const devices = await readTrustedDevices(runtimePaths);
|
|
825
|
+
const device = devices.find((d) => d.deviceId === deviceId);
|
|
826
|
+
if (device) {
|
|
827
|
+
device.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
828
|
+
await saveDevices(devices, runtimePaths);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
async function saveDevices(devices, paths) {
|
|
832
|
+
await writeJsonFile(devicesFilePath(paths), { devices });
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// src/security/app-connect-token.ts
|
|
836
|
+
import crypto from "crypto";
|
|
837
|
+
import path7 from "path";
|
|
838
|
+
var TOKENS_FILE = "app-connect-tokens.json";
|
|
839
|
+
var TOKEN_EXPIRY_MS = 5 * 60 * 1e3;
|
|
840
|
+
function tokensFilePath(paths) {
|
|
841
|
+
return path7.join(paths.homeDir, TOKENS_FILE);
|
|
842
|
+
}
|
|
843
|
+
async function readTokens(paths) {
|
|
844
|
+
const raw = await readJsonFile(tokensFilePath(paths));
|
|
845
|
+
if (!Array.isArray(raw)) return [];
|
|
846
|
+
const now = /* @__PURE__ */ new Date();
|
|
847
|
+
return raw.filter(isValidToken).filter((t) => new Date(t.expiresAt) > now);
|
|
848
|
+
}
|
|
849
|
+
function isValidToken(value) {
|
|
850
|
+
if (!value || typeof value !== "object") return false;
|
|
851
|
+
const t = value;
|
|
852
|
+
return typeof t.token === "string" && typeof t.createdAt === "string" && typeof t.expiresAt === "string";
|
|
853
|
+
}
|
|
854
|
+
async function saveTokens(tokens, paths) {
|
|
855
|
+
await writeJsonFile(tokensFilePath(paths), tokens);
|
|
856
|
+
}
|
|
857
|
+
async function generateAppConnectToken(paths) {
|
|
858
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
859
|
+
const now = /* @__PURE__ */ new Date();
|
|
860
|
+
const token = {
|
|
861
|
+
token: crypto.randomBytes(32).toString("base64url"),
|
|
862
|
+
createdAt: now.toISOString(),
|
|
863
|
+
usedAt: null,
|
|
864
|
+
expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS).toISOString()
|
|
865
|
+
};
|
|
866
|
+
const tokens = await readTokens(runtimePaths);
|
|
867
|
+
tokens.push(token);
|
|
868
|
+
await saveTokens(tokens, runtimePaths);
|
|
869
|
+
return token;
|
|
870
|
+
}
|
|
871
|
+
async function consumeAppConnectToken(tokenValue, paths) {
|
|
872
|
+
const runtimePaths = paths ?? resolveRuntimePaths();
|
|
873
|
+
const tokens = await readTokens(runtimePaths);
|
|
874
|
+
const index = tokens.findIndex((t) => t.token === tokenValue && !t.usedAt);
|
|
875
|
+
if (index === -1) return null;
|
|
876
|
+
tokens[index].usedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
877
|
+
await saveTokens(tokens, runtimePaths);
|
|
878
|
+
return tokens[index];
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/http/auth.ts
|
|
882
|
+
var DEVICE_ID_HEADER = "x-hermeslink-device-id";
|
|
883
|
+
var TOKEN_HEADER = "x-hermeslink-connect-token";
|
|
884
|
+
function requireAuth(paths) {
|
|
885
|
+
return async (ctx, next) => {
|
|
886
|
+
const deviceId = ctx.get(DEVICE_ID_HEADER);
|
|
887
|
+
const connectToken = ctx.get(TOKEN_HEADER);
|
|
888
|
+
if (connectToken) {
|
|
889
|
+
const token = await consumeAppConnectToken(connectToken, paths);
|
|
890
|
+
if (!token) {
|
|
891
|
+
ctx.status = 401;
|
|
892
|
+
ctx.body = { error: "Invalid or expired connect token" };
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const trustedDeviceId = deviceId || token.token.slice(0, 16);
|
|
896
|
+
ctx.state.auth = { deviceId: trustedDeviceId };
|
|
897
|
+
await next();
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (!deviceId) {
|
|
901
|
+
ctx.status = 401;
|
|
902
|
+
ctx.body = { error: "Missing device ID" };
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const trusted = await isDeviceTrusted(deviceId, paths);
|
|
906
|
+
if (!trusted) {
|
|
907
|
+
ctx.status = 401;
|
|
908
|
+
ctx.body = { error: "Device not trusted" };
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
await recordDeviceSeen(deviceId, paths);
|
|
912
|
+
ctx.state.auth = { deviceId };
|
|
913
|
+
await next();
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/http/routes/system.ts
|
|
918
|
+
function createSystemRouter(options) {
|
|
919
|
+
const router = new Router({ prefix: "/api/v1/system" });
|
|
920
|
+
const auth = requireAuth(options.paths);
|
|
921
|
+
router.get("/status", async (ctx) => {
|
|
922
|
+
const state = await readLinkState(options.paths);
|
|
923
|
+
const autostart = await getAutostartStatus();
|
|
924
|
+
const environment = detectRuntimeEnvironment();
|
|
925
|
+
ctx.body = {
|
|
926
|
+
version: LINK_VERSION,
|
|
927
|
+
linkId: options.identity.link_id,
|
|
928
|
+
installId: options.identity.install_id,
|
|
929
|
+
port: options.config.port,
|
|
930
|
+
autostart: {
|
|
931
|
+
supported: autostart.supported,
|
|
932
|
+
enabled: autostart.enabled,
|
|
933
|
+
method: autostart.method
|
|
934
|
+
},
|
|
935
|
+
environment: {
|
|
936
|
+
kind: environment.kind,
|
|
937
|
+
warning: environment.warning
|
|
938
|
+
},
|
|
939
|
+
networkReport: state.networkReport,
|
|
940
|
+
updateAvailable: state.updateAvailable
|
|
941
|
+
};
|
|
942
|
+
});
|
|
943
|
+
router.get("/version", (ctx) => {
|
|
944
|
+
ctx.body = { version: LINK_VERSION };
|
|
945
|
+
});
|
|
946
|
+
router.post("/autostart/enable", auth, async (ctx) => {
|
|
947
|
+
const status = await enableAutostart();
|
|
948
|
+
ctx.body = status;
|
|
949
|
+
});
|
|
950
|
+
router.post("/autostart/disable", auth, async (ctx) => {
|
|
951
|
+
const status = await disableAutostart();
|
|
952
|
+
ctx.body = status;
|
|
953
|
+
});
|
|
954
|
+
router.get("/logs", auth, async (ctx) => {
|
|
955
|
+
const limit = Number(ctx.query.limit) || void 0;
|
|
956
|
+
const entries = await readRecentLogEntries({ paths: options.paths, limit });
|
|
957
|
+
ctx.body = { entries };
|
|
958
|
+
});
|
|
959
|
+
router.get("/logs/gateway", auth, async (ctx) => {
|
|
960
|
+
const limit = Number(ctx.query.limit) || void 0;
|
|
961
|
+
const entries = await readRecentGatewayLogEntries({ paths: options.paths, limit });
|
|
962
|
+
ctx.body = { entries };
|
|
963
|
+
});
|
|
964
|
+
router.get("/updates", auth, async (ctx) => {
|
|
965
|
+
const info = await checkForUpdates({
|
|
966
|
+
relayBaseUrl: options.config.relayBaseUrl,
|
|
967
|
+
paths: options.paths
|
|
968
|
+
});
|
|
969
|
+
ctx.body = info;
|
|
970
|
+
});
|
|
971
|
+
router.post("/updates/dismiss", auth, async (ctx) => {
|
|
972
|
+
await dismissUpdate(options.paths);
|
|
973
|
+
ctx.body = { ok: true };
|
|
974
|
+
});
|
|
975
|
+
return router;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// src/http/routes/statistics.ts
|
|
979
|
+
import Router2 from "@koa/router";
|
|
980
|
+
function createStatisticsRouter(options) {
|
|
981
|
+
const router = new Router2({ prefix: "/api/v1/statistics" });
|
|
982
|
+
const auth = requireAuth(options.paths);
|
|
983
|
+
router.get("/conversations", auth, (ctx) => {
|
|
984
|
+
const { from, to, profile } = ctx.query;
|
|
985
|
+
let query = `SELECT date, profile_name, conversation_count, message_count,
|
|
986
|
+
total_input_tokens, total_output_tokens
|
|
987
|
+
FROM conversation_stats WHERE 1=1`;
|
|
988
|
+
const params = [];
|
|
989
|
+
if (typeof from === "string") {
|
|
990
|
+
query += " AND date >= ?";
|
|
991
|
+
params.push(from);
|
|
992
|
+
}
|
|
993
|
+
if (typeof to === "string") {
|
|
994
|
+
query += " AND date <= ?";
|
|
995
|
+
params.push(to);
|
|
996
|
+
}
|
|
997
|
+
if (typeof profile === "string" && profile) {
|
|
998
|
+
query += " AND profile_name = ?";
|
|
999
|
+
params.push(profile);
|
|
1000
|
+
}
|
|
1001
|
+
query += " ORDER BY date DESC, profile_name ASC LIMIT 500";
|
|
1002
|
+
const rows = options.db.prepare(query).all(...params);
|
|
1003
|
+
ctx.body = { rows };
|
|
1004
|
+
});
|
|
1005
|
+
router.get("/usage", auth, (ctx) => {
|
|
1006
|
+
const { from, to, profile, model } = ctx.query;
|
|
1007
|
+
let query = `SELECT date, profile_name, model,
|
|
1008
|
+
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, run_count
|
|
1009
|
+
FROM run_usage_facts WHERE 1=1`;
|
|
1010
|
+
const params = [];
|
|
1011
|
+
if (typeof from === "string") {
|
|
1012
|
+
query += " AND date >= ?";
|
|
1013
|
+
params.push(from);
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof to === "string") {
|
|
1016
|
+
query += " AND date <= ?";
|
|
1017
|
+
params.push(to);
|
|
1018
|
+
}
|
|
1019
|
+
if (typeof profile === "string" && profile) {
|
|
1020
|
+
query += " AND profile_name = ?";
|
|
1021
|
+
params.push(profile);
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof model === "string" && model) {
|
|
1024
|
+
query += " AND model = ?";
|
|
1025
|
+
params.push(model);
|
|
1026
|
+
}
|
|
1027
|
+
query += " ORDER BY date DESC, profile_name ASC, model ASC LIMIT 1000";
|
|
1028
|
+
const rows = options.db.prepare(query).all(...params);
|
|
1029
|
+
ctx.body = { rows };
|
|
1030
|
+
});
|
|
1031
|
+
router.post("/conversations/upsert", auth, (ctx) => {
|
|
1032
|
+
const body = ctx.request.body;
|
|
1033
|
+
if (!body || typeof body !== "object") {
|
|
1034
|
+
ctx.status = 400;
|
|
1035
|
+
ctx.body = { error: "Invalid body" };
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const { date, profileName, conversationCount, messageCount, totalInputTokens, totalOutputTokens } = body;
|
|
1039
|
+
if (!date || !profileName) {
|
|
1040
|
+
ctx.status = 400;
|
|
1041
|
+
ctx.body = { error: "Missing required fields: date, profileName" };
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
options.db.prepare(
|
|
1045
|
+
`INSERT INTO conversation_stats
|
|
1046
|
+
(date, profile_name, conversation_count, message_count, total_input_tokens, total_output_tokens)
|
|
1047
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1048
|
+
ON CONFLICT (date, profile_name) DO UPDATE SET
|
|
1049
|
+
conversation_count = excluded.conversation_count,
|
|
1050
|
+
message_count = excluded.message_count,
|
|
1051
|
+
total_input_tokens = excluded.total_input_tokens,
|
|
1052
|
+
total_output_tokens = excluded.total_output_tokens`
|
|
1053
|
+
).run(
|
|
1054
|
+
date,
|
|
1055
|
+
profileName,
|
|
1056
|
+
Number(conversationCount) || 0,
|
|
1057
|
+
Number(messageCount) || 0,
|
|
1058
|
+
Number(totalInputTokens) || 0,
|
|
1059
|
+
Number(totalOutputTokens) || 0
|
|
1060
|
+
);
|
|
1061
|
+
ctx.body = { ok: true };
|
|
1062
|
+
});
|
|
1063
|
+
router.post("/usage/upsert", auth, (ctx) => {
|
|
1064
|
+
const body = ctx.request.body;
|
|
1065
|
+
if (!body || typeof body !== "object") {
|
|
1066
|
+
ctx.status = 400;
|
|
1067
|
+
ctx.body = { error: "Invalid body" };
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const { date, profileName, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, runCount } = body;
|
|
1071
|
+
if (!date || !profileName || !model) {
|
|
1072
|
+
ctx.status = 400;
|
|
1073
|
+
ctx.body = { error: "Missing required fields: date, profileName, model" };
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
options.db.prepare(
|
|
1077
|
+
`INSERT INTO run_usage_facts
|
|
1078
|
+
(date, profile_name, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, run_count)
|
|
1079
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1080
|
+
ON CONFLICT (date, profile_name, model) DO UPDATE SET
|
|
1081
|
+
input_tokens = excluded.input_tokens,
|
|
1082
|
+
output_tokens = excluded.output_tokens,
|
|
1083
|
+
cache_creation_tokens = excluded.cache_creation_tokens,
|
|
1084
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
1085
|
+
run_count = excluded.run_count`
|
|
1086
|
+
).run(
|
|
1087
|
+
date,
|
|
1088
|
+
profileName,
|
|
1089
|
+
model,
|
|
1090
|
+
Number(inputTokens) || 0,
|
|
1091
|
+
Number(outputTokens) || 0,
|
|
1092
|
+
Number(cacheCreationTokens) || 0,
|
|
1093
|
+
Number(cacheReadTokens) || 0,
|
|
1094
|
+
Number(runCount) || 0
|
|
1095
|
+
);
|
|
1096
|
+
ctx.body = { ok: true };
|
|
1097
|
+
});
|
|
1098
|
+
return router;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/relay/relay-client.ts
|
|
1102
|
+
import { EventEmitter } from "events";
|
|
1103
|
+
|
|
1104
|
+
// src/identity/identity.ts
|
|
1105
|
+
import { generateKeyPairSync, randomUUID as randomUUID2, sign } from "crypto";
|
|
1106
|
+
import { chmod as chmod2, mkdir as mkdir4 } from "fs/promises";
|
|
1107
|
+
import { z } from "zod";
|
|
1108
|
+
var linkIdentitySchema = z.object({
|
|
1109
|
+
install_id: z.string().min(1),
|
|
1110
|
+
link_id: z.string().min(1).nullable().optional(),
|
|
1111
|
+
public_key_pem: z.string().min(1),
|
|
1112
|
+
private_key_pem: z.string().min(1),
|
|
1113
|
+
created_at: z.string().min(1),
|
|
1114
|
+
updated_at: z.string().min(1)
|
|
1115
|
+
});
|
|
1116
|
+
async function loadIdentity(paths = resolveRuntimePaths()) {
|
|
1117
|
+
const value = await readJsonFile(paths.identityFile);
|
|
1118
|
+
if (value === null) {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
return linkIdentitySchema.parse(value);
|
|
1122
|
+
}
|
|
1123
|
+
async function ensureIdentity(paths = resolveRuntimePaths()) {
|
|
1124
|
+
const existing = await loadIdentity(paths);
|
|
1125
|
+
if (existing) {
|
|
1126
|
+
return existing;
|
|
1127
|
+
}
|
|
1128
|
+
await mkdir4(paths.homeDir, { recursive: true, mode: 448 });
|
|
1129
|
+
await chmod2(paths.homeDir, 448).catch(() => void 0);
|
|
1130
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
1131
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1132
|
+
const identity = {
|
|
1133
|
+
install_id: `install_${randomUUID2().replaceAll("-", "")}`,
|
|
1134
|
+
link_id: null,
|
|
1135
|
+
public_key_pem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
1136
|
+
private_key_pem: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
1137
|
+
created_at: now,
|
|
1138
|
+
updated_at: now
|
|
1139
|
+
};
|
|
1140
|
+
await writeJsonFile(paths.identityFile, identity);
|
|
1141
|
+
return identity;
|
|
1142
|
+
}
|
|
1143
|
+
async function saveAssignedLinkId(linkId, paths = resolveRuntimePaths()) {
|
|
1144
|
+
const identity = await ensureIdentity(paths);
|
|
1145
|
+
const next = {
|
|
1146
|
+
...identity,
|
|
1147
|
+
link_id: linkId,
|
|
1148
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1149
|
+
};
|
|
1150
|
+
await writeJsonFile(paths.identityFile, next);
|
|
1151
|
+
return next;
|
|
1152
|
+
}
|
|
1153
|
+
function signRelayNonce(identity, nonce) {
|
|
1154
|
+
return signIdentityPayload(identity, nonce);
|
|
1155
|
+
}
|
|
1156
|
+
function signIdentityPayload(identity, payload) {
|
|
1157
|
+
const signature = sign(null, Buffer.from(payload, "utf8"), identity.private_key_pem);
|
|
1158
|
+
return signature.toString("base64url");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// src/relay/bootstrap.ts
|
|
1162
|
+
async function bootstrapWithRelay(options) {
|
|
1163
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
1164
|
+
const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
|
|
1165
|
+
const nonceResponse = await fetcher(`${baseUrl}/api/v1/relay/links/nonce`, {
|
|
1166
|
+
method: "POST",
|
|
1167
|
+
headers: { "content-type": "application/json" },
|
|
1168
|
+
body: JSON.stringify({ install_id: options.identity.install_id })
|
|
1169
|
+
});
|
|
1170
|
+
if (!nonceResponse.ok) {
|
|
1171
|
+
throw new Error(`Relay nonce request failed: ${nonceResponse.status}`);
|
|
1172
|
+
}
|
|
1173
|
+
const nonceBody = await nonceResponse.json();
|
|
1174
|
+
const nonce = typeof nonceBody.nonce === "string" ? nonceBody.nonce : null;
|
|
1175
|
+
if (!nonce) {
|
|
1176
|
+
throw new Error("Relay did not return a nonce");
|
|
1177
|
+
}
|
|
1178
|
+
const signature = signRelayNonce(options.identity, nonce);
|
|
1179
|
+
const registerResponse = await fetcher(`${baseUrl}/api/v1/relay/links`, {
|
|
1180
|
+
method: "POST",
|
|
1181
|
+
headers: { "content-type": "application/json" },
|
|
1182
|
+
body: JSON.stringify({
|
|
1183
|
+
install_id: options.identity.install_id,
|
|
1184
|
+
public_key_pem: options.identity.public_key_pem,
|
|
1185
|
+
nonce,
|
|
1186
|
+
signature,
|
|
1187
|
+
port: options.port
|
|
1188
|
+
})
|
|
1189
|
+
});
|
|
1190
|
+
if (!registerResponse.ok) {
|
|
1191
|
+
throw new Error(`Relay registration failed: ${registerResponse.status}`);
|
|
1192
|
+
}
|
|
1193
|
+
const registerBody = await registerResponse.json();
|
|
1194
|
+
const linkId = typeof registerBody.link_id === "string" ? registerBody.link_id : null;
|
|
1195
|
+
const token = typeof registerBody.token === "string" ? registerBody.token : null;
|
|
1196
|
+
if (!linkId || !token) {
|
|
1197
|
+
throw new Error("Relay registration response missing link_id or token");
|
|
1198
|
+
}
|
|
1199
|
+
return { linkId, token };
|
|
1200
|
+
}
|
|
1201
|
+
async function refreshRelayToken(options) {
|
|
1202
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
1203
|
+
const baseUrl = options.relayBaseUrl.replace(/\/+$/u, "");
|
|
1204
|
+
const nonceResponse = await fetcher(`${baseUrl}/api/v1/relay/links/nonce`, {
|
|
1205
|
+
method: "POST",
|
|
1206
|
+
headers: { "content-type": "application/json" },
|
|
1207
|
+
body: JSON.stringify({ install_id: options.identity.install_id })
|
|
1208
|
+
});
|
|
1209
|
+
if (!nonceResponse.ok) {
|
|
1210
|
+
throw new Error(`Relay nonce request failed: ${nonceResponse.status}`);
|
|
1211
|
+
}
|
|
1212
|
+
const nonceBody = await nonceResponse.json();
|
|
1213
|
+
const nonce = typeof nonceBody.nonce === "string" ? nonceBody.nonce : null;
|
|
1214
|
+
if (!nonce) throw new Error("Relay did not return a nonce");
|
|
1215
|
+
const signature = signRelayNonce(options.identity, nonce);
|
|
1216
|
+
const tokenResponse = await fetcher(
|
|
1217
|
+
`${baseUrl}/api/v1/relay/links/${options.identity.link_id}/token`,
|
|
1218
|
+
{
|
|
1219
|
+
method: "POST",
|
|
1220
|
+
headers: { "content-type": "application/json" },
|
|
1221
|
+
body: JSON.stringify({
|
|
1222
|
+
install_id: options.identity.install_id,
|
|
1223
|
+
nonce,
|
|
1224
|
+
signature
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
);
|
|
1228
|
+
if (!tokenResponse.ok) {
|
|
1229
|
+
throw new Error(`Relay token refresh failed: ${tokenResponse.status}`);
|
|
1230
|
+
}
|
|
1231
|
+
const tokenBody = await tokenResponse.json();
|
|
1232
|
+
const token = typeof tokenBody.token === "string" ? tokenBody.token : null;
|
|
1233
|
+
if (!token) throw new Error("Relay did not return a token");
|
|
1234
|
+
return token;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// src/relay/relay-client.ts
|
|
1238
|
+
var WS;
|
|
1239
|
+
try {
|
|
1240
|
+
WS = WebSocket;
|
|
1241
|
+
} catch {
|
|
1242
|
+
}
|
|
1243
|
+
var RelayClient = class extends EventEmitter {
|
|
1244
|
+
options;
|
|
1245
|
+
ws = null;
|
|
1246
|
+
state = "disconnected";
|
|
1247
|
+
token;
|
|
1248
|
+
logger;
|
|
1249
|
+
reconnectTimer = null;
|
|
1250
|
+
pingTimer = null;
|
|
1251
|
+
reconnectDelay;
|
|
1252
|
+
closed = false;
|
|
1253
|
+
constructor(options) {
|
|
1254
|
+
super();
|
|
1255
|
+
this.options = options;
|
|
1256
|
+
this.token = options.token;
|
|
1257
|
+
this.reconnectDelay = options.reconnectDelayMs ?? 1e3;
|
|
1258
|
+
this.logger = createFileLogger({ paths: options.paths });
|
|
1259
|
+
}
|
|
1260
|
+
get currentState() {
|
|
1261
|
+
return this.state;
|
|
1262
|
+
}
|
|
1263
|
+
start() {
|
|
1264
|
+
if (this.closed) return;
|
|
1265
|
+
this.connect();
|
|
1266
|
+
}
|
|
1267
|
+
stop() {
|
|
1268
|
+
this.closed = true;
|
|
1269
|
+
this.clearTimers();
|
|
1270
|
+
if (this.ws) {
|
|
1271
|
+
this.state = "closing";
|
|
1272
|
+
try {
|
|
1273
|
+
this.ws.close(1e3, "client shutdown");
|
|
1274
|
+
} catch {
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
send(message) {
|
|
1279
|
+
if (this.state !== "connected" || !this.ws) return;
|
|
1280
|
+
try {
|
|
1281
|
+
this.ws.send(JSON.stringify(message));
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
this.logger.warn("Failed to send relay message", { error: String(err) }).catch(() => void 0);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
connect() {
|
|
1287
|
+
if (this.closed) return;
|
|
1288
|
+
this.state = "connecting";
|
|
1289
|
+
const wsUrl = this.buildWsUrl();
|
|
1290
|
+
try {
|
|
1291
|
+
const ws = new WS(wsUrl, {
|
|
1292
|
+
headers: { authorization: `Bearer ${this.token}` }
|
|
1293
|
+
});
|
|
1294
|
+
this.ws = ws;
|
|
1295
|
+
ws.addEventListener("open", () => {
|
|
1296
|
+
this.reconnectDelay = this.options.reconnectDelayMs ?? 1e3;
|
|
1297
|
+
this.state = "connected";
|
|
1298
|
+
this.startPing();
|
|
1299
|
+
this.emit("connected");
|
|
1300
|
+
this.logger.info("Relay WebSocket connected").catch(() => void 0);
|
|
1301
|
+
});
|
|
1302
|
+
ws.addEventListener("message", (event) => {
|
|
1303
|
+
try {
|
|
1304
|
+
const message = JSON.parse(event.data);
|
|
1305
|
+
this.emit("message", message);
|
|
1306
|
+
} catch {
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
ws.addEventListener("close", (event) => {
|
|
1310
|
+
this.stopPing();
|
|
1311
|
+
this.ws = null;
|
|
1312
|
+
this.state = "disconnected";
|
|
1313
|
+
this.emit("disconnected", { code: event.code, reason: event.reason });
|
|
1314
|
+
if (!this.closed) {
|
|
1315
|
+
this.scheduleReconnect();
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
ws.addEventListener("error", (event) => {
|
|
1319
|
+
this.logger.warn("Relay WebSocket error", { error: String(event) }).catch(() => void 0);
|
|
1320
|
+
});
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
this.state = "disconnected";
|
|
1323
|
+
this.logger.warn("Failed to create WebSocket", { error: String(err) }).catch(() => void 0);
|
|
1324
|
+
if (!this.closed) {
|
|
1325
|
+
this.scheduleReconnect();
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
buildWsUrl() {
|
|
1330
|
+
const base = this.options.relayBaseUrl.replace(/\/+$/u, "").replace(/^http/u, "ws");
|
|
1331
|
+
return `${base}/api/v1/relay/links/${this.options.identity.link_id}/ws`;
|
|
1332
|
+
}
|
|
1333
|
+
scheduleReconnect() {
|
|
1334
|
+
const maxDelay = this.options.maxReconnectDelayMs ?? 3e4;
|
|
1335
|
+
const delay = Math.min(this.reconnectDelay, maxDelay);
|
|
1336
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, maxDelay);
|
|
1337
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1338
|
+
if (this.closed) return;
|
|
1339
|
+
this.maybeRefreshTokenAndReconnect().catch(() => void 0);
|
|
1340
|
+
}, delay);
|
|
1341
|
+
}
|
|
1342
|
+
async maybeRefreshTokenAndReconnect() {
|
|
1343
|
+
try {
|
|
1344
|
+
this.token = await refreshRelayToken({
|
|
1345
|
+
relayBaseUrl: this.options.relayBaseUrl,
|
|
1346
|
+
identity: this.options.identity,
|
|
1347
|
+
fetchImpl: this.options.fetchImpl
|
|
1348
|
+
});
|
|
1349
|
+
} catch {
|
|
1350
|
+
}
|
|
1351
|
+
this.connect();
|
|
1352
|
+
}
|
|
1353
|
+
startPing() {
|
|
1354
|
+
const intervalMs = this.options.pingIntervalMs ?? 25e3;
|
|
1355
|
+
this.pingTimer = setInterval(() => {
|
|
1356
|
+
if (this.state === "connected" && this.ws) {
|
|
1357
|
+
try {
|
|
1358
|
+
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
1359
|
+
} catch {
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}, intervalMs);
|
|
1363
|
+
}
|
|
1364
|
+
stopPing() {
|
|
1365
|
+
if (this.pingTimer !== null) {
|
|
1366
|
+
clearInterval(this.pingTimer);
|
|
1367
|
+
this.pingTimer = null;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
clearTimers() {
|
|
1371
|
+
this.stopPing();
|
|
1372
|
+
if (this.reconnectTimer !== null) {
|
|
1373
|
+
clearTimeout(this.reconnectTimer);
|
|
1374
|
+
this.reconnectTimer = null;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
// src/network/topology.ts
|
|
1380
|
+
import os5 from "os";
|
|
1381
|
+
|
|
1382
|
+
// src/config/config.ts
|
|
1383
|
+
var defaultLinkConfig = {
|
|
1384
|
+
port: LINK_DEFAULT_PORT,
|
|
1385
|
+
lanHost: null,
|
|
1386
|
+
serverBaseUrl: "https://hermes-server.clawpilot.me",
|
|
1387
|
+
relayBaseUrl: "https://hermes-relay.clawpilot.me",
|
|
1388
|
+
appConnectTokenIssuer: "https://hermes-server.clawpilot.me",
|
|
1389
|
+
appConnectTokenAudience: "hermes-link",
|
|
1390
|
+
language: "auto",
|
|
1391
|
+
logLevel: "warn"
|
|
1392
|
+
};
|
|
1393
|
+
async function loadConfig(paths = resolveRuntimePaths()) {
|
|
1394
|
+
const existing = await readJsonFile(paths.configFile);
|
|
1395
|
+
const language = normalizeConfiguredLanguage(existing?.language);
|
|
1396
|
+
const lanHost = normalizeLanHost(existing?.lanHost);
|
|
1397
|
+
const logLevel = normalizeLogLevel(existing?.logLevel ?? process.env.HERMESLINK_LOG_LEVEL);
|
|
1398
|
+
return {
|
|
1399
|
+
...defaultLinkConfig,
|
|
1400
|
+
...existing ?? {},
|
|
1401
|
+
language,
|
|
1402
|
+
lanHost,
|
|
1403
|
+
logLevel
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
async function saveConfig(patch, paths = resolveRuntimePaths()) {
|
|
1407
|
+
const current = await loadConfig(paths);
|
|
1408
|
+
const next = {
|
|
1409
|
+
...current,
|
|
1410
|
+
...patch,
|
|
1411
|
+
logLevel: patch.logLevel === void 0 ? current.logLevel : normalizeLogLevel(patch.logLevel)
|
|
1412
|
+
};
|
|
1413
|
+
await writeJsonFile(paths.configFile, next);
|
|
1414
|
+
return next;
|
|
1415
|
+
}
|
|
1416
|
+
function normalizeConfiguredLanguage(language) {
|
|
1417
|
+
if (language === "zh-CN" || language === "en" || language === "auto") {
|
|
1418
|
+
return language;
|
|
1419
|
+
}
|
|
1420
|
+
return defaultLinkConfig.language;
|
|
1421
|
+
}
|
|
1422
|
+
function normalizeLogLevel(level) {
|
|
1423
|
+
if (level === "debug" || level === "info" || level === "warn" || level === "error") {
|
|
1424
|
+
return level;
|
|
1425
|
+
}
|
|
1426
|
+
return defaultLinkConfig.logLevel;
|
|
1427
|
+
}
|
|
1428
|
+
function normalizeLanHost(value) {
|
|
1429
|
+
if (value === null || value === void 0) {
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
if (typeof value !== "string") {
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
const host = value.trim().replace(/^\[/u, "").replace(/\]$/u, "");
|
|
1436
|
+
if (!host) {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
if (!isUsableLanIpv4(host)) {
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
return host;
|
|
1443
|
+
}
|
|
1444
|
+
function isUsableLanIpv4(value) {
|
|
1445
|
+
const parts = value.split(".").map((part) => Number.parseInt(part, 10));
|
|
1446
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
1447
|
+
return false;
|
|
1448
|
+
}
|
|
1449
|
+
const [first, second, , fourth] = parts;
|
|
1450
|
+
const privateRange = first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
1451
|
+
return privateRange && fourth !== 0 && fourth !== 255;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/network/topology.ts
|
|
1455
|
+
var VIRTUAL_INTERFACE_NAME_PATTERN = /(docker|veth|vmnet|vmenet|vbox|virtualbox|vmware|tailscale|zerotier|wireguard|utun|virbr|hyper-v|vethernet|loopback|\blo\b|^lo\d*$|^br-|^bridge\d+$|^zt|^tun|^tap|awdl|llw|anpi|gif|stf|ipsec|ppp)/iu;
|
|
1456
|
+
var MAX_LAN_IPS = 4;
|
|
1457
|
+
var MAX_PUBLIC_IPV4S = 2;
|
|
1458
|
+
var MAX_PUBLIC_IPV6S = 2;
|
|
1459
|
+
async function discoverRouteCandidates(options) {
|
|
1460
|
+
const environment = detectRuntimeEnvironment();
|
|
1461
|
+
const configuredLanHost = normalizeLanHost(options.configuredLanHost);
|
|
1462
|
+
const lanIps = configuredLanHost ? [configuredLanHost] : environment.lanAutoDiscoveryUsable ? discoverLanIps() : [];
|
|
1463
|
+
const publicIps = options.relayBootstrapToken || options.observePublicRoute ? await observePublicRoute(options).catch(() => ({ publicIpv4s: [], publicIpv6s: [] })) : { publicIpv4s: [], publicIpv6s: [] };
|
|
1464
|
+
const publicIpv4s = unique(publicIps.publicIpv4s.filter(isUsablePublicIpv4)).slice(0, MAX_PUBLIC_IPV4S);
|
|
1465
|
+
const publicIpv6s = unique(publicIps.publicIpv6s.filter(isUsablePublicIpv6)).slice(0, MAX_PUBLIC_IPV6S);
|
|
1466
|
+
const preferredUrls = [
|
|
1467
|
+
...lanIps.map((ip) => buildDirectUrl(ip, options.port)),
|
|
1468
|
+
...publicIpv4s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
1469
|
+
...publicIpv6s.map((ip) => buildDirectUrl(ip, options.port)),
|
|
1470
|
+
`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/links/${options.linkId}`
|
|
1471
|
+
];
|
|
1472
|
+
return { lanIps, publicIpv4s, publicIpv6s, preferredUrls, environment };
|
|
1473
|
+
}
|
|
1474
|
+
function discoverLanIps() {
|
|
1475
|
+
return discoverLanIpsFromInterfaces(os5.networkInterfaces());
|
|
1476
|
+
}
|
|
1477
|
+
function discoverLanIpsFromInterfaces(interfaces) {
|
|
1478
|
+
const result = /* @__PURE__ */ new Set();
|
|
1479
|
+
const candidates = [];
|
|
1480
|
+
for (const [name, items] of Object.entries(interfaces)) {
|
|
1481
|
+
if (shouldIgnoreInterface(name)) continue;
|
|
1482
|
+
for (const item of items ?? []) {
|
|
1483
|
+
if (!item.internal && item.address && item.family === "IPv4" && isUsableLanIpv42(item.address, item.netmask)) {
|
|
1484
|
+
candidates.push({ name, address: item.address });
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
for (const candidate of candidates.sort(compareLanCandidate)) {
|
|
1489
|
+
result.add(candidate.address);
|
|
1490
|
+
}
|
|
1491
|
+
return [...result].slice(0, MAX_LAN_IPS);
|
|
1492
|
+
}
|
|
1493
|
+
async function observePublicRoute(options) {
|
|
1494
|
+
const fetcher = options.fetchImpl ?? fetch;
|
|
1495
|
+
const response = await fetcher(
|
|
1496
|
+
`${options.relayBaseUrl.replace(/\/+$/u, "")}/api/v1/relay/public-route/observe`,
|
|
1497
|
+
{
|
|
1498
|
+
method: "POST",
|
|
1499
|
+
headers: {
|
|
1500
|
+
"content-type": "application/json",
|
|
1501
|
+
...options.relayBootstrapToken ? { authorization: `Bearer ${options.relayBootstrapToken}` } : {}
|
|
1502
|
+
},
|
|
1503
|
+
body: JSON.stringify({
|
|
1504
|
+
install_id: options.installId,
|
|
1505
|
+
link_id: options.linkId,
|
|
1506
|
+
public_key_pem: options.publicKeyPem
|
|
1507
|
+
})
|
|
1508
|
+
}
|
|
1509
|
+
);
|
|
1510
|
+
const payload = await response.json().catch(() => null);
|
|
1511
|
+
const record = typeof payload?.record === "object" && payload.record !== null ? payload.record : null;
|
|
1512
|
+
const observed = typeof payload?.observed === "object" && payload.observed !== null ? payload.observed : null;
|
|
1513
|
+
const values = [
|
|
1514
|
+
readIpRecord(record?.ipv4),
|
|
1515
|
+
readIpRecord(record?.ipv6),
|
|
1516
|
+
typeof observed?.ip === "string" ? observed.ip : null
|
|
1517
|
+
].filter((v) => Boolean(v));
|
|
1518
|
+
return {
|
|
1519
|
+
publicIpv4s: unique(values.filter(isUsablePublicIpv4)),
|
|
1520
|
+
publicIpv6s: unique(values.filter(isUsablePublicIpv6))
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
function readIpRecord(value) {
|
|
1524
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1525
|
+
const ip = value.ip;
|
|
1526
|
+
return typeof ip === "string" && ip.trim() ? ip.trim() : null;
|
|
1527
|
+
}
|
|
1528
|
+
function buildDirectUrl(ip, port) {
|
|
1529
|
+
return `http://${ip.includes(":") ? `[${ip}]` : ip}:${port}`;
|
|
1530
|
+
}
|
|
1531
|
+
function shouldIgnoreInterface(name) {
|
|
1532
|
+
return !name.trim() || VIRTUAL_INTERFACE_NAME_PATTERN.test(name);
|
|
1533
|
+
}
|
|
1534
|
+
function compareLanCandidate(left, right) {
|
|
1535
|
+
const priority = interfacePriority(left.name) - interfacePriority(right.name);
|
|
1536
|
+
return priority || left.name.localeCompare(right.name) || left.address.localeCompare(right.address);
|
|
1537
|
+
}
|
|
1538
|
+
function interfacePriority(name) {
|
|
1539
|
+
return /^(en|eth|wlan|wi-fi|wifi)/iu.test(name) ? 0 : 1;
|
|
1540
|
+
}
|
|
1541
|
+
function isUsableLanIpv42(address, netmask) {
|
|
1542
|
+
return isPrivateIpv4(address) && !isNetworkOrBroadcastIpv4Address(address, netmask);
|
|
1543
|
+
}
|
|
1544
|
+
function isUsablePublicIpv4(address) {
|
|
1545
|
+
return isValidIpv4(address) && !isSpecialIpv4(address);
|
|
1546
|
+
}
|
|
1547
|
+
function isUsablePublicIpv6(address) {
|
|
1548
|
+
const normalized = address.toLowerCase();
|
|
1549
|
+
return normalized.includes(":") && !normalized.startsWith("fe80:") && !normalized.startsWith("fc") && !normalized.startsWith("fd") && !normalized.startsWith("ff") && normalized !== "::" && normalized !== "::1";
|
|
1550
|
+
}
|
|
1551
|
+
function isPrivateIpv4(address) {
|
|
1552
|
+
const parts = parseIpv4Segments(address);
|
|
1553
|
+
if (!parts) return false;
|
|
1554
|
+
const [first, second] = parts;
|
|
1555
|
+
return first === 10 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
1556
|
+
}
|
|
1557
|
+
function isSpecialIpv4(address) {
|
|
1558
|
+
const parts = parseIpv4Segments(address);
|
|
1559
|
+
if (!parts) return true;
|
|
1560
|
+
const [first, second, third, fourth] = parts;
|
|
1561
|
+
return first === 0 || first === 10 || first === 127 || first >= 224 || first === 100 && second >= 64 && second <= 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 192 && second === 0 && third === 2 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first === 255 && second === 255 && third === 255 && fourth === 255;
|
|
1562
|
+
}
|
|
1563
|
+
function isNetworkOrBroadcastIpv4Address(address, netmask) {
|
|
1564
|
+
const addressParts = parseIpv4Segments(address);
|
|
1565
|
+
const netmaskParts = netmask ? parseIpv4Segments(netmask) : null;
|
|
1566
|
+
if (!addressParts) return true;
|
|
1567
|
+
if (!netmaskParts) {
|
|
1568
|
+
const last = addressParts[3];
|
|
1569
|
+
return last === 0 || last === 255;
|
|
1570
|
+
}
|
|
1571
|
+
const addressInt = ipv4SegmentsToInt(addressParts);
|
|
1572
|
+
const netmaskInt = ipv4SegmentsToInt(netmaskParts);
|
|
1573
|
+
const hostMask = ~netmaskInt >>> 0;
|
|
1574
|
+
if (hostMask === 0) return false;
|
|
1575
|
+
const networkInt = addressInt & netmaskInt;
|
|
1576
|
+
const broadcastInt = (networkInt | hostMask) >>> 0;
|
|
1577
|
+
return addressInt === networkInt || addressInt === broadcastInt;
|
|
1578
|
+
}
|
|
1579
|
+
function isValidIpv4(address) {
|
|
1580
|
+
return Boolean(parseIpv4Segments(address));
|
|
1581
|
+
}
|
|
1582
|
+
function parseIpv4Segments(address) {
|
|
1583
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(address)) return null;
|
|
1584
|
+
const parts = address.split(".").map((part) => Number.parseInt(part, 10));
|
|
1585
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
|
|
1586
|
+
return parts;
|
|
1587
|
+
}
|
|
1588
|
+
function ipv4SegmentsToInt(parts) {
|
|
1589
|
+
return (parts[0] << 24 >>> 0 | parts[1] << 16 | parts[2] << 8 | parts[3]) >>> 0;
|
|
1590
|
+
}
|
|
1591
|
+
function unique(values) {
|
|
1592
|
+
return [...new Set(values)];
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// src/storage/sqlite.ts
|
|
1596
|
+
import Database from "better-sqlite3";
|
|
1597
|
+
function openSqliteDatabase(filePath, options = {}) {
|
|
1598
|
+
return new Database(filePath, {
|
|
1599
|
+
...options.readonly === void 0 ? {} : { readonly: options.readonly, fileMustExist: options.readonly },
|
|
1600
|
+
...options.timeout === void 0 ? {} : { timeout: options.timeout }
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/storage/link-database.ts
|
|
1605
|
+
import { mkdir as mkdir5 } from "fs/promises";
|
|
1606
|
+
import path8 from "path";
|
|
1607
|
+
async function initLinkDatabase(paths) {
|
|
1608
|
+
await mkdir5(path8.dirname(paths.databaseFile), { recursive: true, mode: 448 });
|
|
1609
|
+
const db = openDb(paths);
|
|
1610
|
+
try {
|
|
1611
|
+
db.exec(`
|
|
1612
|
+
PRAGMA foreign_keys = ON;
|
|
1613
|
+
PRAGMA busy_timeout = 5000;
|
|
1614
|
+
PRAGMA journal_mode = WAL;
|
|
1615
|
+
|
|
1616
|
+
CREATE TABLE IF NOT EXISTS conversation_stats (
|
|
1617
|
+
conversation_id TEXT PRIMARY KEY,
|
|
1618
|
+
kind TEXT NOT NULL,
|
|
1619
|
+
title TEXT NOT NULL,
|
|
1620
|
+
status TEXT NOT NULL,
|
|
1621
|
+
hermes_session_id TEXT NOT NULL,
|
|
1622
|
+
profile_uid TEXT,
|
|
1623
|
+
profile_name_snapshot TEXT,
|
|
1624
|
+
profile TEXT,
|
|
1625
|
+
model TEXT,
|
|
1626
|
+
provider TEXT,
|
|
1627
|
+
context_window INTEGER,
|
|
1628
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1629
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1630
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1631
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
1632
|
+
run_count INTEGER NOT NULL DEFAULT 0,
|
|
1633
|
+
created_at TEXT NOT NULL,
|
|
1634
|
+
updated_at TEXT NOT NULL,
|
|
1635
|
+
deleted_at TEXT,
|
|
1636
|
+
stats_updated_at TEXT NOT NULL
|
|
1637
|
+
);
|
|
1638
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_stats_status
|
|
1639
|
+
ON conversation_stats(status);
|
|
1640
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_stats_updated_at
|
|
1641
|
+
ON conversation_stats(updated_at);
|
|
1642
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_stats_model
|
|
1643
|
+
ON conversation_stats(model);
|
|
1644
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_stats_profile
|
|
1645
|
+
ON conversation_stats(profile);
|
|
1646
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_stats_profile_uid
|
|
1647
|
+
ON conversation_stats(profile_uid);
|
|
1648
|
+
CREATE INDEX IF NOT EXISTS idx_conversation_stats_profile_name_snapshot
|
|
1649
|
+
ON conversation_stats(profile_name_snapshot);
|
|
1650
|
+
|
|
1651
|
+
CREATE TABLE IF NOT EXISTS profile_registry (
|
|
1652
|
+
profile_uid TEXT PRIMARY KEY,
|
|
1653
|
+
profile_name TEXT NOT NULL UNIQUE,
|
|
1654
|
+
profile_path TEXT NOT NULL,
|
|
1655
|
+
display_name TEXT,
|
|
1656
|
+
description TEXT,
|
|
1657
|
+
avatar_type TEXT NOT NULL DEFAULT 'default',
|
|
1658
|
+
avatar_url TEXT,
|
|
1659
|
+
created_at TEXT NOT NULL,
|
|
1660
|
+
updated_at TEXT NOT NULL
|
|
1661
|
+
);
|
|
1662
|
+
CREATE INDEX IF NOT EXISTS idx_profile_registry_profile_name
|
|
1663
|
+
ON profile_registry(profile_name);
|
|
1664
|
+
|
|
1665
|
+
CREATE TABLE IF NOT EXISTS run_usage_facts (
|
|
1666
|
+
run_id TEXT PRIMARY KEY,
|
|
1667
|
+
conversation_id TEXT NOT NULL,
|
|
1668
|
+
profile_uid TEXT,
|
|
1669
|
+
profile_name_snapshot TEXT,
|
|
1670
|
+
profile TEXT,
|
|
1671
|
+
model TEXT,
|
|
1672
|
+
provider TEXT,
|
|
1673
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1674
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1675
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1676
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
1677
|
+
started_at TEXT NOT NULL,
|
|
1678
|
+
completed_at TEXT NOT NULL,
|
|
1679
|
+
updated_at TEXT NOT NULL
|
|
1680
|
+
);
|
|
1681
|
+
CREATE INDEX IF NOT EXISTS idx_run_usage_facts_completed_at
|
|
1682
|
+
ON run_usage_facts(completed_at);
|
|
1683
|
+
CREATE INDEX IF NOT EXISTS idx_run_usage_facts_conversation_id
|
|
1684
|
+
ON run_usage_facts(conversation_id);
|
|
1685
|
+
CREATE INDEX IF NOT EXISTS idx_run_usage_facts_model
|
|
1686
|
+
ON run_usage_facts(model);
|
|
1687
|
+
CREATE INDEX IF NOT EXISTS idx_run_usage_facts_profile_uid
|
|
1688
|
+
ON run_usage_facts(profile_uid);
|
|
1689
|
+
CREATE INDEX IF NOT EXISTS idx_run_usage_facts_profile_name_snapshot
|
|
1690
|
+
ON run_usage_facts(profile_name_snapshot);
|
|
1691
|
+
`);
|
|
1692
|
+
} finally {
|
|
1693
|
+
db.close();
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
function openDb(paths) {
|
|
1697
|
+
const db = openSqliteDatabase(paths.databaseFile, { timeout: 5e3 });
|
|
1698
|
+
db.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;");
|
|
1699
|
+
return db;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// src/http/app.ts
|
|
1703
|
+
async function startLinkService(options) {
|
|
1704
|
+
const { config, identity, paths, relayToken } = options;
|
|
1705
|
+
const logger = createLogger({ paths, fileName: "link.log", level: config.logLevel });
|
|
1706
|
+
await initLinkDatabase(paths);
|
|
1707
|
+
const db = openSqliteDatabase(paths.databaseFile, { timeout: 5e3 });
|
|
1708
|
+
const app = new Koa();
|
|
1709
|
+
app.use(cors({ origin: "*" }));
|
|
1710
|
+
app.use(bodyParser({ jsonLimit: "10mb" }));
|
|
1711
|
+
app.use(async (ctx, next) => {
|
|
1712
|
+
try {
|
|
1713
|
+
await next();
|
|
1714
|
+
} catch (err) {
|
|
1715
|
+
const error = err;
|
|
1716
|
+
ctx.status = error.status ?? error.statusCode ?? 500;
|
|
1717
|
+
ctx.body = { error: error.message ?? "Internal server error" };
|
|
1718
|
+
logger.error({ path: ctx.path, status: ctx.status, err: error.message }, "Request error");
|
|
1719
|
+
}
|
|
1720
|
+
});
|
|
1721
|
+
const rootRouter = new Router3();
|
|
1722
|
+
rootRouter.get("/pair", (ctx) => {
|
|
1723
|
+
const connectToken = typeof ctx.query.connect_token === "string" ? ctx.query.connect_token : "";
|
|
1724
|
+
ctx.type = "text/html";
|
|
1725
|
+
ctx.body = buildPairingPage({ port: config.port, connectToken });
|
|
1726
|
+
});
|
|
1727
|
+
app.use(rootRouter.routes());
|
|
1728
|
+
app.use(rootRouter.allowedMethods());
|
|
1729
|
+
const systemRouter = createSystemRouter({ config, identity, paths });
|
|
1730
|
+
app.use(systemRouter.routes());
|
|
1731
|
+
app.use(systemRouter.allowedMethods());
|
|
1732
|
+
const statsRouter = createStatisticsRouter({ db, paths });
|
|
1733
|
+
app.use(statsRouter.routes());
|
|
1734
|
+
app.use(statsRouter.allowedMethods());
|
|
1735
|
+
const server = await new Promise((resolve, reject) => {
|
|
1736
|
+
const s = app.listen(config.port, "127.0.0.1", () => resolve(s));
|
|
1737
|
+
s.once("error", reject);
|
|
1738
|
+
});
|
|
1739
|
+
const relayClient = new RelayClient({
|
|
1740
|
+
relayBaseUrl: config.relayBaseUrl,
|
|
1741
|
+
identity,
|
|
1742
|
+
token: relayToken,
|
|
1743
|
+
paths
|
|
1744
|
+
});
|
|
1745
|
+
relayClient.start();
|
|
1746
|
+
reportNetwork({ config, identity, paths }).catch(() => void 0);
|
|
1747
|
+
const stop = async () => {
|
|
1748
|
+
relayClient.stop();
|
|
1749
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
1750
|
+
db.close();
|
|
1751
|
+
logger.info("Link service stopped");
|
|
1752
|
+
};
|
|
1753
|
+
return { app, server, relayClient, stop };
|
|
1754
|
+
}
|
|
1755
|
+
async function reportNetwork(options) {
|
|
1756
|
+
const candidates = await discoverRouteCandidates({
|
|
1757
|
+
configuredLanHost: options.config.lanHost,
|
|
1758
|
+
relayBaseUrl: options.config.relayBaseUrl,
|
|
1759
|
+
linkId: options.identity.link_id ?? "",
|
|
1760
|
+
port: options.config.port,
|
|
1761
|
+
installId: options.identity.install_id,
|
|
1762
|
+
publicKeyPem: options.identity.public_key_pem
|
|
1763
|
+
});
|
|
1764
|
+
await updateNetworkReportState(
|
|
1765
|
+
{
|
|
1766
|
+
lastReportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1767
|
+
preferredUrls: candidates.preferredUrls,
|
|
1768
|
+
lanIps: candidates.lanIps,
|
|
1769
|
+
publicIpv4s: candidates.publicIpv4s,
|
|
1770
|
+
publicIpv6s: candidates.publicIpv6s
|
|
1771
|
+
},
|
|
1772
|
+
options.paths
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
function buildPairingPage(options) {
|
|
1776
|
+
return `<!DOCTYPE html>
|
|
1777
|
+
<html lang="en">
|
|
1778
|
+
<head>
|
|
1779
|
+
<meta charset="UTF-8" />
|
|
1780
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1781
|
+
<title>Hermes Link \u2014 Pairing</title>
|
|
1782
|
+
<style>
|
|
1783
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1784
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f0f0f; color: #e5e5e5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1785
|
+
.card { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 12px; padding: 2rem; max-width: 420px; width: 100%; text-align: center; }
|
|
1786
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
|
1787
|
+
p { color: #a0a0a0; font-size: 0.9rem; margin-bottom: 1.5rem; line-height: 1.5; }
|
|
1788
|
+
.token { font-family: monospace; background: #0f0f0f; border: 1px solid #333; border-radius: 6px; padding: 0.75rem 1rem; font-size: 0.85rem; word-break: break-all; color: #7dd3fc; margin-bottom: 1.5rem; }
|
|
1789
|
+
button { background: #3b82f6; color: #fff; border: none; border-radius: 8px; padding: 0.75rem 1.5rem; font-size: 0.95rem; cursor: pointer; width: 100%; }
|
|
1790
|
+
button:hover { background: #2563eb; }
|
|
1791
|
+
.status { margin-top: 1rem; font-size: 0.85rem; color: #6ee7b7; display: none; }
|
|
1792
|
+
</style>
|
|
1793
|
+
</head>
|
|
1794
|
+
<body>
|
|
1795
|
+
<div class="card">
|
|
1796
|
+
<h1>Hermes Link Pairing</h1>
|
|
1797
|
+
<p>Use this page to pair your device with the local Hermes Link service running on port ${options.port}.</p>
|
|
1798
|
+
<div class="token" id="token">${options.connectToken}</div>
|
|
1799
|
+
<button onclick="pair()">Pair This Device</button>
|
|
1800
|
+
<div class="status" id="status">Paired successfully!</div>
|
|
1801
|
+
</div>
|
|
1802
|
+
<script>
|
|
1803
|
+
async function pair() {
|
|
1804
|
+
const token = document.getElementById('token').textContent;
|
|
1805
|
+
try {
|
|
1806
|
+
const res = await fetch('http://127.0.0.1:${options.port}/api/v1/system/status', {
|
|
1807
|
+
headers: { 'x-hermeslink-connect-token': token }
|
|
1808
|
+
});
|
|
1809
|
+
if (res.ok) {
|
|
1810
|
+
document.getElementById('status').style.display = 'block';
|
|
1811
|
+
}
|
|
1812
|
+
} catch (e) {
|
|
1813
|
+
alert('Pairing failed: ' + e.message);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
</script>
|
|
1817
|
+
</body>
|
|
1818
|
+
</html>`;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
export {
|
|
1822
|
+
LINK_COMMAND,
|
|
1823
|
+
LINK_VERSION,
|
|
1824
|
+
DAEMON_LOG_FILE,
|
|
1825
|
+
resolveRuntimePaths,
|
|
1826
|
+
loadConfig,
|
|
1827
|
+
saveConfig,
|
|
1828
|
+
normalizeLanHost,
|
|
1829
|
+
loadIdentity,
|
|
1830
|
+
ensureIdentity,
|
|
1831
|
+
saveAssignedLinkId,
|
|
1832
|
+
enableAutostart,
|
|
1833
|
+
disableAutostart,
|
|
1834
|
+
getAutostartStatus,
|
|
1835
|
+
currentCliScriptPath,
|
|
1836
|
+
detectRuntimeEnvironment,
|
|
1837
|
+
createRotatingTextLogWriter,
|
|
1838
|
+
readRecentLogEntries,
|
|
1839
|
+
readRecentGatewayLogEntries,
|
|
1840
|
+
bootstrapWithRelay,
|
|
1841
|
+
generateAppConnectToken,
|
|
1842
|
+
startLinkService
|
|
1843
|
+
};
|