@nordbyte/nordrelay 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/CHANGELOG.md +26 -0
- package/README.md +147 -19
- package/dist/access-control.js +6 -0
- package/dist/agent-adapter.js +60 -0
- package/dist/audit-log.js +54 -0
- package/dist/bot-preferences.js +13 -9
- package/dist/bot-ui.js +6 -0
- package/dist/bot.js +526 -26
- package/dist/channel-adapter.js +58 -0
- package/dist/codex-session.js +3 -1
- package/dist/config.js +47 -0
- package/dist/context-key.js +23 -0
- package/dist/index.js +47 -2
- package/dist/logger.js +24 -1
- package/dist/operations.js +340 -15
- package/dist/prompt-store.js +33 -11
- package/dist/relay-runtime.js +908 -0
- package/dist/session-locks.js +81 -0
- package/dist/session-registry.js +11 -7
- package/dist/settings-service.js +253 -0
- package/dist/state-backend.js +83 -0
- package/dist/web-dashboard.js +890 -0
- package/dist/web-state.js +131 -0
- package/docker-compose.yml +1 -1
- package/package.json +4 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +235 -13
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createDocumentStore } from "./state-backend.js";
|
|
3
|
+
const DEFAULT_CHAT_LIMIT = 300;
|
|
4
|
+
const DEFAULT_ACTIVITY_LIMIT = 1000;
|
|
5
|
+
export class WebChatStore {
|
|
6
|
+
store;
|
|
7
|
+
maxMessages;
|
|
8
|
+
constructor(workspace, backend = "json", maxMessages = DEFAULT_CHAT_LIMIT) {
|
|
9
|
+
this.store = createDocumentStore({
|
|
10
|
+
workspace,
|
|
11
|
+
fileName: "web-chat.json",
|
|
12
|
+
sqliteKey: "web-chat",
|
|
13
|
+
backend,
|
|
14
|
+
});
|
|
15
|
+
this.maxMessages = maxMessages;
|
|
16
|
+
}
|
|
17
|
+
append(input) {
|
|
18
|
+
const payload = this.readPayload();
|
|
19
|
+
const threadId = input.threadId || "pending";
|
|
20
|
+
const messages = payload.messagesByThread[threadId] ?? [];
|
|
21
|
+
const message = {
|
|
22
|
+
id: randomId(),
|
|
23
|
+
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
24
|
+
...input,
|
|
25
|
+
threadId,
|
|
26
|
+
};
|
|
27
|
+
messages.push(message);
|
|
28
|
+
if (messages.length > this.maxMessages) {
|
|
29
|
+
messages.splice(0, messages.length - this.maxMessages);
|
|
30
|
+
}
|
|
31
|
+
payload.messagesByThread[threadId] = messages;
|
|
32
|
+
this.store.write(payload);
|
|
33
|
+
return message;
|
|
34
|
+
}
|
|
35
|
+
list(threadId, limit = 200) {
|
|
36
|
+
const messages = this.readPayload().messagesByThread[threadId || "pending"] ?? [];
|
|
37
|
+
return messages.slice(-Math.max(1, Math.min(this.maxMessages, limit)));
|
|
38
|
+
}
|
|
39
|
+
clear(threadId) {
|
|
40
|
+
const payload = this.readPayload();
|
|
41
|
+
const key = threadId || "pending";
|
|
42
|
+
const count = payload.messagesByThread[key]?.length ?? 0;
|
|
43
|
+
delete payload.messagesByThread[key];
|
|
44
|
+
this.store.write(payload);
|
|
45
|
+
return count;
|
|
46
|
+
}
|
|
47
|
+
readPayload() {
|
|
48
|
+
const payload = this.store.read();
|
|
49
|
+
if (!payload || payload.version !== 1 || !payload.messagesByThread || typeof payload.messagesByThread !== "object") {
|
|
50
|
+
return { version: 1, messagesByThread: {} };
|
|
51
|
+
}
|
|
52
|
+
const messagesByThread = {};
|
|
53
|
+
for (const [threadId, messages] of Object.entries(payload.messagesByThread)) {
|
|
54
|
+
if (Array.isArray(messages)) {
|
|
55
|
+
messagesByThread[threadId] = messages.filter(isWebChatMessage).slice(-this.maxMessages);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { version: 1, messagesByThread };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class WebActivityStore {
|
|
62
|
+
store;
|
|
63
|
+
maxEvents;
|
|
64
|
+
constructor(workspace, backend = "json", maxEvents = DEFAULT_ACTIVITY_LIMIT) {
|
|
65
|
+
this.store = createDocumentStore({
|
|
66
|
+
workspace,
|
|
67
|
+
fileName: "web-activity.json",
|
|
68
|
+
sqliteKey: "web-activity",
|
|
69
|
+
backend,
|
|
70
|
+
});
|
|
71
|
+
this.maxEvents = maxEvents;
|
|
72
|
+
}
|
|
73
|
+
append(input) {
|
|
74
|
+
const payload = this.readPayload();
|
|
75
|
+
const event = {
|
|
76
|
+
id: randomId(),
|
|
77
|
+
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
78
|
+
...input,
|
|
79
|
+
};
|
|
80
|
+
payload.events.push(event);
|
|
81
|
+
if (payload.events.length > this.maxEvents) {
|
|
82
|
+
payload.events.splice(0, payload.events.length - this.maxEvents);
|
|
83
|
+
}
|
|
84
|
+
this.store.write(payload);
|
|
85
|
+
return event;
|
|
86
|
+
}
|
|
87
|
+
list(options = {}) {
|
|
88
|
+
const limit = Math.max(1, Math.min(500, options.limit ?? 100));
|
|
89
|
+
return this.readPayload().events
|
|
90
|
+
.filter((event) => !options.source || options.source === "all" || event.source === options.source)
|
|
91
|
+
.filter((event) => !options.status || options.status === "all" || event.status === options.status)
|
|
92
|
+
.slice(-limit)
|
|
93
|
+
.reverse();
|
|
94
|
+
}
|
|
95
|
+
readPayload() {
|
|
96
|
+
const payload = this.store.read();
|
|
97
|
+
if (!payload || payload.version !== 1 || !Array.isArray(payload.events)) {
|
|
98
|
+
return { version: 1, events: [] };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
version: 1,
|
|
102
|
+
events: payload.events.filter(isWebActivityEvent).slice(-this.maxEvents),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function isWebChatMessage(value) {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const candidate = value;
|
|
111
|
+
return typeof candidate.id === "string" &&
|
|
112
|
+
typeof candidate.threadId === "string" &&
|
|
113
|
+
typeof candidate.text === "string" &&
|
|
114
|
+
typeof candidate.timestamp === "string" &&
|
|
115
|
+
["user", "agent", "system", "tool"].includes(candidate.role) &&
|
|
116
|
+
["web", "cli"].includes(candidate.source);
|
|
117
|
+
}
|
|
118
|
+
function isWebActivityEvent(value) {
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const candidate = value;
|
|
123
|
+
return typeof candidate.id === "string" &&
|
|
124
|
+
typeof candidate.timestamp === "string" &&
|
|
125
|
+
typeof candidate.type === "string" &&
|
|
126
|
+
["web", "cli"].includes(candidate.source) &&
|
|
127
|
+
["queued", "running", "completed", "failed", "aborted", "info"].includes(candidate.status);
|
|
128
|
+
}
|
|
129
|
+
function randomId() {
|
|
130
|
+
return randomUUID().replace(/-/g, "").slice(0, 12);
|
|
131
|
+
}
|
package/docker-compose.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nordbyte/nordrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Remote control plane for coding agents across messaging channels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Ricardo",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dist/",
|
|
31
31
|
"plugins/",
|
|
32
32
|
"launchd/",
|
|
33
|
+
"CHANGELOG.md",
|
|
33
34
|
".env.example",
|
|
34
35
|
"Dockerfile",
|
|
35
36
|
"docker-compose.yml"
|
|
@@ -41,6 +42,8 @@
|
|
|
41
42
|
"foreground": "node plugins/nordrelay/scripts/nordrelay.mjs foreground",
|
|
42
43
|
"prepack": "npm run build",
|
|
43
44
|
"prepublishOnly": "npm run check && npm test && npm run build",
|
|
45
|
+
"security:audit": "npm audit --audit-level=high",
|
|
46
|
+
"sbom": "npm sbom --json > sbom.json",
|
|
44
47
|
"status": "node plugins/nordrelay/scripts/nordrelay.mjs status",
|
|
45
48
|
"start": "node plugins/nordrelay/scripts/nordrelay.mjs start",
|
|
46
49
|
"stop": "node plugins/nordrelay/scripts/nordrelay.mjs stop",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nordrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run a remote-control bridge for coding agents. The current adapter connects Codex sessions to Telegram with streaming replies, multi-session controls, attachments, voice input, model selection, thread browsing, and handback.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Ricardo",
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import fsp from "node:fs/promises";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import process from "node:process";
|
|
8
|
+
import readline from "node:readline/promises";
|
|
7
9
|
import { spawn } from "node:child_process";
|
|
8
10
|
import { fileURLToPath } from "node:url";
|
|
9
11
|
|
|
10
|
-
const VERSION = "0.
|
|
12
|
+
const VERSION = "0.3.0";
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
11
14
|
const APP_NAME = "nordrelay";
|
|
12
15
|
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
13
16
|
const PLUGIN_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
|
|
14
17
|
const DEFAULT_MARKETPLACE_ROOT = path.resolve(PLUGIN_ROOT, "../..");
|
|
15
18
|
const RUNTIME_ROOT = findRuntimeRoot();
|
|
16
|
-
const DEFAULT_HOME = path.join(os.homedir(), ".
|
|
19
|
+
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
17
20
|
|
|
18
21
|
function nowIso() {
|
|
19
22
|
return new Date().toISOString();
|
|
@@ -31,12 +34,23 @@ function parseArgs(argv) {
|
|
|
31
34
|
rawFlags: copy,
|
|
32
35
|
home: process.env.NORDRELAY_HOME || DEFAULT_HOME,
|
|
33
36
|
dropPendingUpdates: !envFlag("NORDRELAY_KEEP_PENDING_UPDATES"),
|
|
37
|
+
force: false,
|
|
38
|
+
host: process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1",
|
|
39
|
+
port: Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10),
|
|
34
40
|
};
|
|
35
41
|
|
|
36
42
|
for (let i = 0; i < copy.length; i += 1) {
|
|
37
43
|
const arg = copy[i];
|
|
38
44
|
if (arg === "--home") options.home = requireValue(copy, ++i, arg);
|
|
39
45
|
else if (arg === "--keep-pending-updates") options.dropPendingUpdates = false;
|
|
46
|
+
else if (arg === "--force") options.force = true;
|
|
47
|
+
else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
|
|
48
|
+
else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
|
|
49
|
+
else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
|
|
50
|
+
else if (arg === "--admin-id") options.telegramAdminUserIds = requireValue(copy, ++i, arg);
|
|
51
|
+
else if (arg === "--state-backend") options.stateBackend = requireValue(copy, ++i, arg);
|
|
52
|
+
else if (arg === "--enable-pi") options.enablePi = true;
|
|
53
|
+
else if (arg === "--disable-codex") options.disableCodex = true;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
options.pidFile = path.join(options.home, "nordrelay.pid");
|
|
@@ -63,16 +77,11 @@ async function mkdirp(dir) {
|
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
function loadEnvFiles(home) {
|
|
66
|
-
const
|
|
67
|
-
path.
|
|
68
|
-
path.join(
|
|
69
|
-
path.join(PLUGIN_ROOT, ".env"),
|
|
70
|
-
path.join(home, "nordrelay.env"),
|
|
71
|
-
];
|
|
80
|
+
const envPath = process.env.NORDRELAY_ENV_FILE
|
|
81
|
+
? path.resolve(process.env.NORDRELAY_ENV_FILE)
|
|
82
|
+
: path.join(home, "nordrelay.env");
|
|
72
83
|
|
|
73
|
-
|
|
74
|
-
loadEnvFile(envPath);
|
|
75
|
-
}
|
|
84
|
+
loadEnvFile(envPath);
|
|
76
85
|
|
|
77
86
|
normalizeEnvAliases();
|
|
78
87
|
}
|
|
@@ -193,7 +202,7 @@ async function commandStart(options) {
|
|
|
193
202
|
await fsp.rm(options.pidFile, { force: true });
|
|
194
203
|
}
|
|
195
204
|
console.log(`Startup failed. Log: ${options.logFile}`);
|
|
196
|
-
console.log(state.error || "Unknown error");
|
|
205
|
+
console.log(state.error || await readStartupError(options.logFile) || "Unknown error");
|
|
197
206
|
process.exitCode = 1;
|
|
198
207
|
return;
|
|
199
208
|
}
|
|
@@ -254,6 +263,125 @@ async function commandStatus(options) {
|
|
|
254
263
|
if (state.error) console.log(`Error: ${state.error}`);
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
async function commandInit(options) {
|
|
267
|
+
await mkdirp(options.home);
|
|
268
|
+
const envPath = path.join(options.home, "nordrelay.env");
|
|
269
|
+
if (fs.existsSync(envPath) && !options.force) {
|
|
270
|
+
console.log(`Config already exists: ${envPath}`);
|
|
271
|
+
console.log("Run with --force to overwrite.");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const rl = process.stdin.isTTY
|
|
276
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
277
|
+
: null;
|
|
278
|
+
try {
|
|
279
|
+
const telegramBotToken = options.telegramBotToken ||
|
|
280
|
+
process.env.TELEGRAM_BOT_TOKEN ||
|
|
281
|
+
await ask(rl, "Telegram bot token", "");
|
|
282
|
+
const telegramAdminUserIds = options.telegramAdminUserIds ||
|
|
283
|
+
process.env.TELEGRAM_ADMIN_USER_IDS ||
|
|
284
|
+
await ask(rl, "Telegram admin user id", "");
|
|
285
|
+
const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
|
|
286
|
+
const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
|
|
287
|
+
const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
|
|
288
|
+
|
|
289
|
+
if (!telegramBotToken) throw new Error("Telegram bot token is required.");
|
|
290
|
+
if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
|
|
291
|
+
if (enableCodex !== "true" && enablePi !== "true") throw new Error("At least one agent must be enabled.");
|
|
292
|
+
|
|
293
|
+
const lines = [
|
|
294
|
+
"# NordRelay local runtime config.",
|
|
295
|
+
"# Keep this file private; it contains bot credentials.",
|
|
296
|
+
`TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
|
|
297
|
+
`TELEGRAM_ADMIN_USER_IDS=${telegramAdminUserIds}`,
|
|
298
|
+
"TELEGRAM_ALLOW_ANY_CHAT=false",
|
|
299
|
+
`NORDRELAY_CODEX_ENABLED=${enableCodex}`,
|
|
300
|
+
`NORDRELAY_PI_ENABLED=${enablePi}`,
|
|
301
|
+
`NORDRELAY_DEFAULT_AGENT=${enableCodex === "true" ? "codex" : "pi"}`,
|
|
302
|
+
`NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
|
|
303
|
+
"TELEGRAM_TRANSPORT=polling",
|
|
304
|
+
"TELEGRAM_AUTO_SEND_ARTIFACTS=false",
|
|
305
|
+
"",
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
|
|
309
|
+
await fsp.chmod(envPath, 0o600).catch(() => {});
|
|
310
|
+
console.log(`Wrote ${envPath}`);
|
|
311
|
+
console.log("Run `nordrelay doctor` to validate the setup.");
|
|
312
|
+
} finally {
|
|
313
|
+
rl?.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function commandDoctor(options) {
|
|
318
|
+
await mkdirp(options.home);
|
|
319
|
+
loadEnvFiles(options.home);
|
|
320
|
+
const checks = [];
|
|
321
|
+
checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
|
|
322
|
+
checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
|
|
323
|
+
checks.push(check("Telegram admin ids", Boolean(process.env.TELEGRAM_ADMIN_USER_IDS), process.env.TELEGRAM_ADMIN_USER_IDS ? "configured" : "missing"));
|
|
324
|
+
checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
|
|
325
|
+
checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
|
|
326
|
+
checks.push(check("Pi enabled flag", process.env.NORDRELAY_PI_ENABLED === "true" || process.env.NORDRELAY_PI_ENABLED === undefined, `NORDRELAY_PI_ENABLED=${process.env.NORDRELAY_PI_ENABLED ?? "false"}`, process.env.NORDRELAY_PI_ENABLED === "true" ? "pass" : "warn"));
|
|
327
|
+
checks.push(check("Codex CLI", Boolean(findExecutable(process.env.CODEX_CLI_PATH || "codex")), process.env.CODEX_CLI_PATH || findExecutable("codex") || "not found", process.env.NORDRELAY_CODEX_ENABLED === "false" ? "warn" : "fail"));
|
|
328
|
+
checks.push(check("Pi CLI", Boolean(findExecutable(process.env.PI_CLI_PATH || "pi")), process.env.PI_CLI_PATH || findExecutable("pi") || "not found", process.env.NORDRELAY_PI_ENABLED === "true" ? "fail" : "warn"));
|
|
329
|
+
checks.push(check("ffmpeg", Boolean(findExecutable("ffmpeg")), findExecutable("ffmpeg") || "not found", "warn"));
|
|
330
|
+
const stateBackendCheck = validateStateBackend();
|
|
331
|
+
checks.push(check("State backend", stateBackendCheck.ok, stateBackendCheck.detail));
|
|
332
|
+
checks.push(check("Runtime entry", Boolean(await resolveRuntimeEntry()), RUNTIME_ROOT));
|
|
333
|
+
|
|
334
|
+
for (const item of checks) {
|
|
335
|
+
console.log(`${item.icon} ${item.name}: ${item.detail}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const failed = checks.filter((item) => item.status === "fail" && !item.ok);
|
|
339
|
+
const warned = checks.filter((item) => item.status === "warn" && !item.ok);
|
|
340
|
+
console.log(`\nSummary: ${failed.length} failed, ${warned.length} warnings.`);
|
|
341
|
+
if (failed.length > 0) process.exitCode = 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function commandWeb(options) {
|
|
345
|
+
await mkdirp(options.home);
|
|
346
|
+
loadEnvFiles(options.home);
|
|
347
|
+
const host = options.host || "127.0.0.1";
|
|
348
|
+
const port = Number.isFinite(options.port) ? options.port : 31878;
|
|
349
|
+
const entry = await resolveWebRuntimeEntry();
|
|
350
|
+
if (!entry) {
|
|
351
|
+
throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const env = {
|
|
355
|
+
...process.env,
|
|
356
|
+
NORDRELAY_HOME: options.home,
|
|
357
|
+
NORDRELAY_SOURCE_ROOT: RUNTIME_ROOT,
|
|
358
|
+
NORDRELAY_DASHBOARD_HOST: host,
|
|
359
|
+
NORDRELAY_DASHBOARD_PORT: String(port),
|
|
360
|
+
};
|
|
361
|
+
const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
|
|
362
|
+
cwd: RUNTIME_ROOT,
|
|
363
|
+
env,
|
|
364
|
+
stdio: "inherit",
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const forwardSignal = (signal) => {
|
|
368
|
+
if (isProcessRunning(child.pid)) {
|
|
369
|
+
child.kill(signal);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
process.once("SIGINT", () => forwardSignal("SIGINT"));
|
|
373
|
+
process.once("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
374
|
+
|
|
375
|
+
const exit = await new Promise((resolve) => {
|
|
376
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
377
|
+
});
|
|
378
|
+
if (exit.signal) {
|
|
379
|
+
process.kill(process.pid, exit.signal);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
process.exit(exit.code ?? 0);
|
|
383
|
+
}
|
|
384
|
+
|
|
257
385
|
async function commandForeground(options) {
|
|
258
386
|
await mkdirp(options.home);
|
|
259
387
|
loadEnvFiles(options.home);
|
|
@@ -307,12 +435,14 @@ async function commandForeground(options) {
|
|
|
307
435
|
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
308
436
|
});
|
|
309
437
|
|
|
438
|
+
const previousState = await readJson(options.stateFile, {});
|
|
310
439
|
await writeJsonAtomic(options.stateFile, {
|
|
311
440
|
status: exit.code === 0 ? "stopped" : "error",
|
|
312
441
|
pid: process.pid,
|
|
313
442
|
updatedAt: nowIso(),
|
|
314
443
|
exitCode: exit.code,
|
|
315
444
|
signal: exit.signal,
|
|
445
|
+
error: exit.code === 0 ? undefined : previousState.error,
|
|
316
446
|
logFile: options.logFile,
|
|
317
447
|
});
|
|
318
448
|
|
|
@@ -338,6 +468,21 @@ async function resolveRuntimeEntry() {
|
|
|
338
468
|
return null;
|
|
339
469
|
}
|
|
340
470
|
|
|
471
|
+
async function resolveWebRuntimeEntry() {
|
|
472
|
+
const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
|
|
473
|
+
if (fs.existsSync(distEntry)) {
|
|
474
|
+
return { command: process.execPath, args: [distEntry] };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const tsEntry = path.join(RUNTIME_ROOT, "src", "web-dashboard.ts");
|
|
478
|
+
const tsxBin = path.join(RUNTIME_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
|
|
479
|
+
if (fs.existsSync(tsEntry) && fs.existsSync(tsxBin)) {
|
|
480
|
+
return { command: tsxBin, args: [tsEntry] };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
341
486
|
function findRuntimeRoot() {
|
|
342
487
|
const candidates = [
|
|
343
488
|
process.env.NORDRELAY_SOURCE_ROOT,
|
|
@@ -366,6 +511,80 @@ function findRuntimeRoot() {
|
|
|
366
511
|
return DEFAULT_MARKETPLACE_ROOT;
|
|
367
512
|
}
|
|
368
513
|
|
|
514
|
+
async function ask(rl, label, defaultValue) {
|
|
515
|
+
if (!rl) return defaultValue;
|
|
516
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
517
|
+
const answer = (await rl.question(`${label}${suffix}: `)).trim();
|
|
518
|
+
return answer || defaultValue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function askChoice(rl, label, defaultValue) {
|
|
522
|
+
const value = (await ask(rl, label, defaultValue)).toLowerCase();
|
|
523
|
+
if (["1", "yes", "y", "true", "on"].includes(value)) return "true";
|
|
524
|
+
if (["0", "no", "n", "false", "off"].includes(value)) return "false";
|
|
525
|
+
return value || defaultValue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function check(name, ok, detail, status = "fail") {
|
|
529
|
+
return {
|
|
530
|
+
name,
|
|
531
|
+
ok,
|
|
532
|
+
detail,
|
|
533
|
+
status,
|
|
534
|
+
icon: ok ? "✅" : status === "warn" ? "⚠️" : "❌",
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function findExecutable(command) {
|
|
539
|
+
if (!command) return null;
|
|
540
|
+
if (command.includes(path.sep) && fs.existsSync(command)) return command;
|
|
541
|
+
const paths = (process.env.PATH || "").split(path.delimiter);
|
|
542
|
+
for (const dir of paths) {
|
|
543
|
+
const candidate = path.join(dir, command);
|
|
544
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function validateStateBackend() {
|
|
550
|
+
const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
|
|
551
|
+
if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
|
|
552
|
+
if (backend !== "sqlite") return { ok: false, detail: `Invalid NORDRELAY_STATE_BACKEND=${backend}` };
|
|
553
|
+
try {
|
|
554
|
+
const Database = require("better-sqlite3");
|
|
555
|
+
const filePath = path.join(process.cwd(), ".nordrelay", "state.sqlite");
|
|
556
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
557
|
+
const db = new Database(filePath);
|
|
558
|
+
db.exec([
|
|
559
|
+
"CREATE TABLE IF NOT EXISTS documents (",
|
|
560
|
+
"key TEXT PRIMARY KEY,",
|
|
561
|
+
"json TEXT NOT NULL,",
|
|
562
|
+
"updated_at TEXT NOT NULL",
|
|
563
|
+
")",
|
|
564
|
+
].join(" "));
|
|
565
|
+
db.close?.();
|
|
566
|
+
return { ok: true, detail: `NORDRELAY_STATE_BACKEND=sqlite (${filePath})` };
|
|
567
|
+
} catch (error) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
detail: `NORDRELAY_STATE_BACKEND=sqlite failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function readStartupError(logFile) {
|
|
576
|
+
try {
|
|
577
|
+
const lines = (await fsp.readFile(logFile, "utf8")).split(/\r?\n/).filter(Boolean).slice(-80).reverse();
|
|
578
|
+
const startupLine = lines.find((line) => line.includes("Failed to start NordRelay:"));
|
|
579
|
+
if (startupLine) return startupLine.replace(/^.*Failed to start NordRelay:\s*/, "");
|
|
580
|
+
const errorLine = lines.find((line) => /\bERROR\b/i.test(line));
|
|
581
|
+
if (errorLine) return errorLine;
|
|
582
|
+
return lines[0] || null;
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
369
588
|
function sleep(ms) {
|
|
370
589
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
371
590
|
}
|
|
@@ -375,6 +594,9 @@ async function main() {
|
|
|
375
594
|
if (options.command === "start") return commandStart(options);
|
|
376
595
|
if (options.command === "stop") return commandStop(options);
|
|
377
596
|
if (options.command === "status") return commandStatus(options);
|
|
597
|
+
if (options.command === "init") return commandInit(options);
|
|
598
|
+
if (options.command === "doctor") return commandDoctor(options);
|
|
599
|
+
if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
|
|
378
600
|
if (options.command === "restart") {
|
|
379
601
|
await commandStop(options);
|
|
380
602
|
return commandStart(options);
|
|
@@ -386,7 +608,7 @@ async function main() {
|
|
|
386
608
|
}
|
|
387
609
|
|
|
388
610
|
console.error(`Unknown command: ${options.command}`);
|
|
389
|
-
console.error("Usage: nordrelay [start|stop|restart|status|foreground]");
|
|
611
|
+
console.error("Usage: nordrelay [init|doctor|web|start|stop|restart|status|foreground|version]");
|
|
390
612
|
process.exitCode = 2;
|
|
391
613
|
}
|
|
392
614
|
|