@nordbyte/nordrelay 0.5.1 → 0.6.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/.env.example +65 -11
- package/README.md +97 -23
- package/dist/access-control.js +1 -0
- package/dist/activity-events.js +44 -0
- package/dist/agent-updates.js +18 -2
- package/dist/audit-log.js +40 -2
- package/dist/bot-rendering.js +10 -7
- package/dist/bot.js +492 -7
- package/dist/channel-actions.js +7 -2
- package/dist/channel-adapter.js +34 -7
- package/dist/channel-command-service.js +156 -0
- package/dist/channel-turn-service.js +237 -0
- package/dist/codex-cli.js +1 -1
- package/dist/config-metadata.js +80 -13
- package/dist/config.js +77 -7
- package/dist/context-key.js +77 -5
- package/dist/discord-artifacts.js +165 -0
- package/dist/discord-bot.js +2014 -0
- package/dist/discord-channel-runtime.js +133 -0
- package/dist/discord-command-surface.js +119 -0
- package/dist/discord-rate-limit.js +141 -0
- package/dist/index.js +16 -5
- package/dist/job-store.js +127 -0
- package/dist/metrics.js +41 -0
- package/dist/operations.js +176 -119
- package/dist/relay-external-activity-monitor.js +47 -6
- package/dist/relay-runtime.js +1003 -268
- package/dist/runtime-cache.js +57 -0
- package/dist/session-locks.js +10 -7
- package/dist/state-backend.js +3 -0
- package/dist/support-bundle.js +18 -1
- package/dist/telegram-access-commands.js +15 -2
- package/dist/telegram-access-middleware.js +16 -3
- package/dist/telegram-agent-commands.js +25 -0
- package/dist/telegram-artifact-commands.js +46 -0
- package/dist/telegram-diagnostics-command.js +5 -50
- package/dist/telegram-general-commands.js +2 -6
- package/dist/telegram-operational-commands.js +14 -6
- package/dist/telegram-queue-commands.js +74 -4
- package/dist/telegram-support-command.js +7 -0
- package/dist/telegram-update-commands.js +27 -0
- package/dist/user-management.js +208 -0
- package/dist/web-api-contract.js +9 -0
- package/dist/web-dashboard-access-routes.js +74 -1
- package/dist/web-dashboard-artifact-routes.js +3 -3
- package/dist/web-dashboard-assets.js +2 -0
- package/dist/web-dashboard-pages.js +97 -13
- package/dist/web-dashboard-runtime-routes.js +53 -8
- package/dist/web-dashboard-session-routes.js +27 -20
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +149 -6
- package/dist/web-state.js +33 -2
- package/dist/webui-assets/dashboard.css +75 -1
- package/dist/webui-assets/dashboard.js +358 -47
- package/package.json +3 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -22
|
@@ -51,6 +51,8 @@ function parseArgs(argv) {
|
|
|
51
51
|
force: false,
|
|
52
52
|
host: undefined,
|
|
53
53
|
port: undefined,
|
|
54
|
+
restartAfterUpdate: true,
|
|
55
|
+
updateMethod: undefined,
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
for (let i = 0; i < copy.length; i += 1) {
|
|
@@ -60,7 +62,14 @@ function parseArgs(argv) {
|
|
|
60
62
|
else if (arg === "--force") options.force = true;
|
|
61
63
|
else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
|
|
62
64
|
else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
|
|
65
|
+
else if (arg === "--method") options.updateMethod = requireValue(copy, ++i, arg);
|
|
66
|
+
else if (arg === "--no-restart") options.restartAfterUpdate = false;
|
|
67
|
+
else if (arg === "--restart") options.restartAfterUpdate = true;
|
|
63
68
|
else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
|
|
69
|
+
else if (arg === "--disable-telegram") options.disableTelegram = true;
|
|
70
|
+
else if (arg === "--enable-discord") options.enableDiscord = true;
|
|
71
|
+
else if (arg === "--discord-token") options.discordBotToken = requireValue(copy, ++i, arg);
|
|
72
|
+
else if (arg === "--discord-client-id") options.discordClientId = requireValue(copy, ++i, arg);
|
|
64
73
|
else if (arg === "--admin-email") options.adminEmail = requireValue(copy, ++i, arg);
|
|
65
74
|
else if (arg === "--admin-name") options.adminName = requireValue(copy, ++i, arg);
|
|
66
75
|
else if (arg === "--admin-password") options.adminPassword = requireValue(copy, ++i, arg);
|
|
@@ -76,6 +85,9 @@ function parseArgs(argv) {
|
|
|
76
85
|
options.pidFile = path.join(options.home, "nordrelay.pid");
|
|
77
86
|
options.stateFile = path.join(options.home, "state.json");
|
|
78
87
|
options.logFile = path.join(options.home, "nordrelay.log");
|
|
88
|
+
options.webPidFile = path.join(options.home, "nordrelay-web.pid");
|
|
89
|
+
options.webStateFile = path.join(options.home, "web-state.json");
|
|
90
|
+
options.webLogFile = path.join(options.home, "nordrelay-web.log");
|
|
79
91
|
return options;
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -170,6 +182,26 @@ async function readPid(pidFile) {
|
|
|
170
182
|
}
|
|
171
183
|
}
|
|
172
184
|
|
|
185
|
+
async function readWebState(options) {
|
|
186
|
+
return await readJson(options.webStateFile, {});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function readWebPid(options) {
|
|
190
|
+
return await readPid(options.webPidFile);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function isWebDashboardRunning(options) {
|
|
194
|
+
return isProcessRunning(await readWebPid(options));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function writeWebState(options, patch) {
|
|
198
|
+
await writeJsonAtomic(options.webStateFile, {
|
|
199
|
+
updatedAt: nowIso(),
|
|
200
|
+
logFile: options.webLogFile,
|
|
201
|
+
...patch,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
173
205
|
function resolveDashboardEndpoint(options, settings = {}) {
|
|
174
206
|
const host = options.host || process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
|
|
175
207
|
const rawPort = options.port ?? Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
|
|
@@ -228,7 +260,9 @@ async function commandStart(options, settings = {}) {
|
|
|
228
260
|
console.log(`Workspace: ${state.workspace || "-"}`);
|
|
229
261
|
console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
|
|
230
262
|
if (!settings.skipWebHint) {
|
|
231
|
-
|
|
263
|
+
const webPid = await readWebPid(options);
|
|
264
|
+
const webHint = isProcessRunning(webPid) ? `(running with PID ${webPid})` : "(run `nordrelay web` to start it)";
|
|
265
|
+
console.log(`WebUI: ${formatDashboardUrl(dashboard)} ${webHint}`);
|
|
232
266
|
}
|
|
233
267
|
console.log(`Log: ${options.logFile}`);
|
|
234
268
|
return;
|
|
@@ -246,7 +280,9 @@ async function commandStart(options, settings = {}) {
|
|
|
246
280
|
|
|
247
281
|
console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
|
|
248
282
|
if (!settings.skipWebHint) {
|
|
249
|
-
|
|
283
|
+
const webPid = await readWebPid(options);
|
|
284
|
+
const webHint = isProcessRunning(webPid) ? `(running with PID ${webPid})` : "(run `nordrelay web` to start it)";
|
|
285
|
+
console.log(`WebUI: ${formatDashboardUrl(dashboard)} ${webHint}`);
|
|
250
286
|
}
|
|
251
287
|
console.log(`Startup is still in progress. Log: ${options.logFile}`);
|
|
252
288
|
}
|
|
@@ -281,7 +317,51 @@ async function waitForState(stateFile, pid, timeoutMs) {
|
|
|
281
317
|
return await readJson(stateFile);
|
|
282
318
|
}
|
|
283
319
|
|
|
284
|
-
async function
|
|
320
|
+
async function stopWebDashboard(options, settings = {}) {
|
|
321
|
+
const pid = await readWebPid(options);
|
|
322
|
+
if (!isProcessRunning(pid)) {
|
|
323
|
+
await fsp.rm(options.webPidFile, { force: true });
|
|
324
|
+
const state = await readWebState(options);
|
|
325
|
+
if (state.status === "running" || state.status === "starting") {
|
|
326
|
+
await writeWebState(options, { status: "stopped", pid: null });
|
|
327
|
+
}
|
|
328
|
+
if (!settings.quiet) {
|
|
329
|
+
console.log("WebUI is not running.");
|
|
330
|
+
}
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
process.kill(pid, "SIGTERM");
|
|
335
|
+
for (let i = 0; i < 40; i += 1) {
|
|
336
|
+
if (!isProcessRunning(pid)) break;
|
|
337
|
+
await sleep(250);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (isProcessRunning(pid)) {
|
|
341
|
+
process.kill(pid, "SIGKILL");
|
|
342
|
+
for (let i = 0; i < 20; i += 1) {
|
|
343
|
+
if (!isProcessRunning(pid)) break;
|
|
344
|
+
await sleep(250);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (isProcessRunning(pid)) {
|
|
349
|
+
console.log(`WebUI PID ${pid} did not exit after SIGTERM/SIGKILL.`);
|
|
350
|
+
process.exitCode = 1;
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
await fsp.rm(options.webPidFile, { force: true });
|
|
354
|
+
await writeWebState(options, { status: "stopped", pid: null });
|
|
355
|
+
if (!settings.quiet) {
|
|
356
|
+
console.log(`Stopped WebUI PID ${pid}.`);
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function commandStop(options, settings = {}) {
|
|
362
|
+
if (!settings.keepWeb) {
|
|
363
|
+
await stopWebDashboard(options);
|
|
364
|
+
}
|
|
285
365
|
const pid = await readPid(options.pidFile);
|
|
286
366
|
if (!isProcessRunning(pid)) {
|
|
287
367
|
console.log("Connector is not running.");
|
|
@@ -308,10 +388,19 @@ async function commandStatus(options) {
|
|
|
308
388
|
loadEnvFiles(options.home);
|
|
309
389
|
const dashboard = resolveDashboardEndpoint(options);
|
|
310
390
|
const pid = await readPid(options.pidFile);
|
|
391
|
+
const webPid = await readWebPid(options);
|
|
311
392
|
const state = await readJson(options.stateFile, {});
|
|
393
|
+
const webState = await readWebState(options);
|
|
312
394
|
const running = isProcessRunning(pid);
|
|
395
|
+
const webRunning = isProcessRunning(webPid);
|
|
396
|
+
const webStatus = webRunning ? "running" : webState.status === "running" || webState.status === "starting" ? "stale" : webState.status || "stopped";
|
|
397
|
+
if (!webRunning && (webState.status === "running" || webState.status === "starting")) {
|
|
398
|
+
await fsp.rm(options.webPidFile, { force: true });
|
|
399
|
+
await writeWebState(options, { status: "stopped", pid: null });
|
|
400
|
+
}
|
|
313
401
|
console.log(`Status: ${state.status || (running ? "running" : "stopped")}`);
|
|
314
402
|
console.log(`PID: ${pid || "-"} (${running ? "running" : "not running"})`);
|
|
403
|
+
console.log(`WebUI PID: ${webPid || "-"} (${webRunning ? "running" : "not running"})`);
|
|
315
404
|
console.log(`Workspace: ${state.workspace || "-"}`);
|
|
316
405
|
console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
|
|
317
406
|
console.log(`Auth: ${state.authenticated === undefined ? "-" : state.authenticated ? "yes" : "no"}`);
|
|
@@ -321,11 +410,250 @@ async function commandStatus(options) {
|
|
|
321
410
|
console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
|
|
322
411
|
console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
|
|
323
412
|
console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
|
|
324
|
-
console.log(`WebUI: ${formatDashboardUrl(dashboard)}`);
|
|
413
|
+
console.log(`WebUI: ${formatDashboardUrl(dashboard)} (${webStatus})`);
|
|
325
414
|
console.log(`Log: ${options.logFile}`);
|
|
415
|
+
console.log(`WebUI log: ${options.webLogFile}`);
|
|
326
416
|
if (state.error) console.log(`Error: ${state.error}`);
|
|
327
417
|
}
|
|
328
418
|
|
|
419
|
+
async function commandUpdate(options) {
|
|
420
|
+
await mkdirp(options.home);
|
|
421
|
+
loadEnvFiles(options.home);
|
|
422
|
+
const method = resolveUpdateMethod(options);
|
|
423
|
+
const updateLog = path.join(options.home, "update.log");
|
|
424
|
+
await mkdirp(path.dirname(updateLog));
|
|
425
|
+
const log = fs.createWriteStream(updateLog, { flags: "a" });
|
|
426
|
+
const sourceRoot = RUNTIME_ROOT;
|
|
427
|
+
const wasRunning = isProcessRunning(await readPid(options.pidFile));
|
|
428
|
+
const summary = method === "npm"
|
|
429
|
+
? "Install latest @nordbyte/nordrelay with npm, verify the CLI, and restart if the connector is running."
|
|
430
|
+
: "Pull origin/main, install dependencies, run check, tests, build, and restart if the connector is running.";
|
|
431
|
+
|
|
432
|
+
console.log(`Starting NordRelay update (${method}).`);
|
|
433
|
+
console.log(`Source: ${sourceRoot}`);
|
|
434
|
+
console.log(`Log: ${updateLog}`);
|
|
435
|
+
logUpdateLine(log, `Starting ${method} connector self-update`);
|
|
436
|
+
logUpdateLine(log, summary);
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
if (method === "npm") {
|
|
440
|
+
await runNpmSelfUpdate(sourceRoot, log);
|
|
441
|
+
} else {
|
|
442
|
+
await runGitSelfUpdate(sourceRoot, log);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (options.restartAfterUpdate && wasRunning) {
|
|
446
|
+
await runLoggedStep(log, "Restart NordRelay connector", process.execPath, [
|
|
447
|
+
SCRIPT_PATH,
|
|
448
|
+
"restart",
|
|
449
|
+
"--keep-pending-updates",
|
|
450
|
+
"--home",
|
|
451
|
+
options.home,
|
|
452
|
+
], { cwd: sourceRoot });
|
|
453
|
+
} else if (options.restartAfterUpdate) {
|
|
454
|
+
logUpdateLine(log, "Connector was not running; restart skipped.");
|
|
455
|
+
console.log("Connector was not running; restart skipped.");
|
|
456
|
+
} else {
|
|
457
|
+
logUpdateLine(log, "Restart skipped by --no-restart.");
|
|
458
|
+
console.log("Restart skipped by --no-restart.");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
logUpdateLine(log, "NordRelay update completed.");
|
|
462
|
+
console.log("NordRelay update completed.");
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
465
|
+
logUpdateLine(log, `ERROR ${message}`);
|
|
466
|
+
console.error(`Update failed: ${message}`);
|
|
467
|
+
process.exitCode = 1;
|
|
468
|
+
} finally {
|
|
469
|
+
await closeLogStream(log);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function resolveUpdateMethod(options) {
|
|
474
|
+
const requested = (options.updateMethod || process.env.NORDRELAY_UPDATE_METHOD || "auto").trim().toLowerCase();
|
|
475
|
+
if (!requested || requested === "auto") {
|
|
476
|
+
return fs.existsSync(path.join(RUNTIME_ROOT, ".git")) ? "git" : "npm";
|
|
477
|
+
}
|
|
478
|
+
if (requested === "npm" || requested === "git") {
|
|
479
|
+
return requested;
|
|
480
|
+
}
|
|
481
|
+
throw new Error(`Unsupported update method "${requested}". Use auto, npm, or git.`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function runNpmSelfUpdate(sourceRoot, log) {
|
|
485
|
+
const npm = resolveNpmSpawnCommand();
|
|
486
|
+
if (!npm) {
|
|
487
|
+
throw new Error("npm was not found. Install Node.js/npm or add npm to PATH.");
|
|
488
|
+
}
|
|
489
|
+
await runLoggedStep(log, "Install latest NordRelay package", npm.command, [
|
|
490
|
+
...npm.argsPrefix,
|
|
491
|
+
"install",
|
|
492
|
+
"-g",
|
|
493
|
+
"@nordbyte/nordrelay@latest",
|
|
494
|
+
], { cwd: os.homedir(), shell: npm.shell });
|
|
495
|
+
await runVerifyNordRelayCli(sourceRoot, log);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function runGitSelfUpdate(sourceRoot, log) {
|
|
499
|
+
const git = resolveRequiredCommand("git");
|
|
500
|
+
const npm = resolveNpmSpawnCommand();
|
|
501
|
+
if (!npm) {
|
|
502
|
+
throw new Error("npm was not found. Install Node.js/npm or add npm to PATH.");
|
|
503
|
+
}
|
|
504
|
+
await runLoggedStep(log, "Pull latest source", git.command, ["pull", "--ff-only", "origin", "main"], { cwd: sourceRoot, shell: git.shell });
|
|
505
|
+
await runLoggedStep(log, "Install dependencies", npm.command, [...npm.argsPrefix, "install"], { cwd: sourceRoot, shell: npm.shell });
|
|
506
|
+
await runLoggedStep(log, "Run checks", npm.command, [...npm.argsPrefix, "run", "check"], { cwd: sourceRoot, shell: npm.shell });
|
|
507
|
+
await runLoggedStep(log, "Run tests", npm.command, [...npm.argsPrefix, "test"], { cwd: sourceRoot, shell: npm.shell });
|
|
508
|
+
await runLoggedStep(log, "Build runtime", npm.command, [...npm.argsPrefix, "run", "build"], { cwd: sourceRoot, shell: npm.shell });
|
|
509
|
+
await runVerifyNordRelayCli(sourceRoot, log);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function runVerifyNordRelayCli(sourceRoot, log) {
|
|
513
|
+
if (fs.existsSync(SCRIPT_PATH)) {
|
|
514
|
+
await runLoggedStep(log, "Verify NordRelay CLI", process.execPath, [SCRIPT_PATH, "version"], { cwd: sourceRoot });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const nordrelay = resolveRequiredCommand("nordrelay");
|
|
518
|
+
await runLoggedStep(log, "Verify NordRelay CLI", nordrelay.command, ["version"], { cwd: os.homedir(), shell: nordrelay.shell });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function runLoggedStep(log, label, command, args, settings = {}) {
|
|
522
|
+
logUpdateLine(log, `${label}: ${formatCommand(command, args)}`);
|
|
523
|
+
console.log(`\n${label}`);
|
|
524
|
+
const useShell = Boolean(settings.shell);
|
|
525
|
+
const child = spawn(useShell ? formatShellCommand(command, args) : command, useShell ? [] : args, {
|
|
526
|
+
cwd: settings.cwd || RUNTIME_ROOT,
|
|
527
|
+
env: process.env,
|
|
528
|
+
shell: useShell,
|
|
529
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
530
|
+
windowsHide: false,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
child.stdout?.on("data", (chunk) => {
|
|
534
|
+
safeWrite(process.stdout, chunk);
|
|
535
|
+
safeWrite(log, chunk);
|
|
536
|
+
});
|
|
537
|
+
child.stderr?.on("data", (chunk) => {
|
|
538
|
+
safeWrite(process.stderr, chunk);
|
|
539
|
+
safeWrite(log, chunk);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const exit = await new Promise((resolve, reject) => {
|
|
543
|
+
child.once("error", reject);
|
|
544
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
if (exit.signal) {
|
|
548
|
+
throw new Error(`${label} stopped with signal ${exit.signal}`);
|
|
549
|
+
}
|
|
550
|
+
if (exit.code !== 0) {
|
|
551
|
+
throw new Error(`${label} failed with exit code ${exit.code ?? "unknown"}`);
|
|
552
|
+
}
|
|
553
|
+
logUpdateLine(log, `${label} completed`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function resolveRequiredCommand(command) {
|
|
557
|
+
const resolved = findExecutable(command);
|
|
558
|
+
if (!resolved) {
|
|
559
|
+
throw new Error(`${command} was not found on PATH.`);
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
command: resolved,
|
|
563
|
+
shell: isWindowsShellScript(resolved),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function resolveNpmSpawnCommand(env = process.env) {
|
|
568
|
+
const npmExecPath = env.npm_execpath?.trim();
|
|
569
|
+
if (npmExecPath && fs.existsSync(npmExecPath)) {
|
|
570
|
+
return {
|
|
571
|
+
command: process.execPath,
|
|
572
|
+
argsPrefix: [npmExecPath],
|
|
573
|
+
shell: false,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const pathMatch = findExecutable("npm", env.PATH);
|
|
578
|
+
if (pathMatch) {
|
|
579
|
+
return {
|
|
580
|
+
command: pathMatch,
|
|
581
|
+
argsPrefix: [],
|
|
582
|
+
shell: isWindowsShellScript(pathMatch),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
for (const candidate of commonNpmCandidates(env)) {
|
|
587
|
+
if (!fs.existsSync(candidate)) continue;
|
|
588
|
+
return {
|
|
589
|
+
command: candidate,
|
|
590
|
+
argsPrefix: [],
|
|
591
|
+
shell: isWindowsShellScript(candidate),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function commonNpmCandidates(env) {
|
|
598
|
+
const names = process.platform === "win32" ? ["npm.cmd", "npm.bat", "npm"] : ["npm"];
|
|
599
|
+
const directories = [
|
|
600
|
+
path.dirname(process.execPath),
|
|
601
|
+
env.APPDATA ? path.join(env.APPDATA, "npm") : undefined,
|
|
602
|
+
env.ProgramFiles ? path.join(env.ProgramFiles, "nodejs") : undefined,
|
|
603
|
+
env["ProgramFiles(x86)"] ? path.join(env["ProgramFiles(x86)"], "nodejs") : undefined,
|
|
604
|
+
].filter(Boolean);
|
|
605
|
+
return directories.flatMap((directory) => names.map((name) => path.join(directory, name)));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function logUpdateLine(log, message) {
|
|
609
|
+
safeWrite(log, `[${nowIso()}] ${message}\n`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function safeWrite(stream, chunk) {
|
|
613
|
+
try {
|
|
614
|
+
stream.write(chunk);
|
|
615
|
+
} catch {
|
|
616
|
+
// Logging must not break the updater if stdout/stderr disappears.
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function closeLogStream(log) {
|
|
621
|
+
return new Promise((resolve) => {
|
|
622
|
+
log.end(resolve);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function formatCommand(command, args) {
|
|
627
|
+
return [command, ...args].map((part) => {
|
|
628
|
+
const text = String(part);
|
|
629
|
+
return /[\s"'$`\\]/.test(text) ? JSON.stringify(text) : text;
|
|
630
|
+
}).join(" ");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function formatShellCommand(command, args) {
|
|
634
|
+
return [command, ...args].map(quoteShellArg).join(" ");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function quoteShellArg(value) {
|
|
638
|
+
if (process.platform === "win32") {
|
|
639
|
+
return quoteWindowsCmdArg(String(value));
|
|
640
|
+
}
|
|
641
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function quoteWindowsCmdArg(value) {
|
|
645
|
+
if (value.length === 0) {
|
|
646
|
+
return "\"\"";
|
|
647
|
+
}
|
|
648
|
+
if (!/[\s"&|<>()^%]/.test(value)) {
|
|
649
|
+
return value;
|
|
650
|
+
}
|
|
651
|
+
return `"${value
|
|
652
|
+
.replace(/%/g, "%%")
|
|
653
|
+
.replace(/(\\*)"/g, '$1$1\\"')
|
|
654
|
+
.replace(/(\\+)$/g, "$1$1")}"`;
|
|
655
|
+
}
|
|
656
|
+
|
|
329
657
|
async function commandInit(options) {
|
|
330
658
|
await mkdirp(options.home);
|
|
331
659
|
const envPath = path.join(options.home, "nordrelay.env");
|
|
@@ -336,9 +664,17 @@ async function commandInit(options) {
|
|
|
336
664
|
return;
|
|
337
665
|
}
|
|
338
666
|
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
await ask(null, "Telegram bot token", "")
|
|
667
|
+
const enableTelegram = options.disableTelegram ? "false" : await askChoice(null, "Enable Telegram", "true");
|
|
668
|
+
const telegramBotToken = enableTelegram === "true"
|
|
669
|
+
? options.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || await ask(null, "Telegram bot token", "")
|
|
670
|
+
: "";
|
|
671
|
+
const enableDiscord = options.enableDiscord ? "true" : await askChoice(null, "Enable Discord", "false");
|
|
672
|
+
const discordBotToken = enableDiscord === "true"
|
|
673
|
+
? options.discordBotToken || process.env.DISCORD_BOT_TOKEN || await ask(null, "Discord bot token", "")
|
|
674
|
+
: "";
|
|
675
|
+
const discordClientId = enableDiscord === "true"
|
|
676
|
+
? options.discordClientId || process.env.DISCORD_CLIENT_ID || await ask(null, "Discord client ID", "")
|
|
677
|
+
: "";
|
|
342
678
|
const adminEmail = options.adminEmail || await ask(null, "Admin email", "");
|
|
343
679
|
const adminName = options.adminName || await ask(null, "Admin name", "Admin");
|
|
344
680
|
const adminPassword = options.adminPassword || await askSecret(null, "Admin password", "");
|
|
@@ -350,7 +686,9 @@ async function commandInit(options) {
|
|
|
350
686
|
const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(null, "Enable Claude Code", "false");
|
|
351
687
|
const stateBackend = options.stateBackend || await askChoice(null, "State backend (json/sqlite)", "json");
|
|
352
688
|
|
|
353
|
-
if (!telegramBotToken) throw new Error("Telegram bot token is required.");
|
|
689
|
+
if (enableTelegram === "true" && !telegramBotToken) throw new Error("Telegram bot token is required when Telegram is enabled.");
|
|
690
|
+
if (enableDiscord === "true" && !discordBotToken) throw new Error("Discord bot token is required when Discord is enabled.");
|
|
691
|
+
if (enableTelegram !== "true" && enableDiscord !== "true") throw new Error("At least one chat adapter must be enabled.");
|
|
354
692
|
if (!adminEmail) throw new Error("Admin email is required.");
|
|
355
693
|
if (!adminPassword) throw new Error("Admin password is required.");
|
|
356
694
|
if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
|
|
@@ -367,7 +705,14 @@ async function commandInit(options) {
|
|
|
367
705
|
const lines = [
|
|
368
706
|
"# NordRelay local runtime config.",
|
|
369
707
|
"# Keep this file private; it contains bot credentials.",
|
|
708
|
+
`TELEGRAM_ENABLED=${enableTelegram}`,
|
|
370
709
|
`TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
|
|
710
|
+
`DISCORD_ENABLED=${enableDiscord}`,
|
|
711
|
+
`DISCORD_BOT_TOKEN=${discordBotToken}`,
|
|
712
|
+
`DISCORD_CLIENT_ID=${discordClientId}`,
|
|
713
|
+
"DISCORD_COMMAND_MODE=both",
|
|
714
|
+
"DISCORD_MESSAGE_CONTENT_ENABLED=true",
|
|
715
|
+
"DISCORD_AUTO_REGISTER_COMMANDS=true",
|
|
371
716
|
`NORDRELAY_CODEX_ENABLED=${enableCodex}`,
|
|
372
717
|
`NORDRELAY_PI_ENABLED=${enablePi}`,
|
|
373
718
|
`NORDRELAY_HERMES_ENABLED=${enableHermes}`,
|
|
@@ -421,6 +766,7 @@ function parseUserFlags(argv) {
|
|
|
421
766
|
else if (arg === "--password") flags.password = requireValue(copy, ++i, arg);
|
|
422
767
|
else if (arg === "--group" || arg === "--groups") flags.groups = requireValue(copy, ++i, arg);
|
|
423
768
|
else if (arg === "--telegram-user-id") flags.telegramUserId = Number.parseInt(requireValue(copy, ++i, arg), 10);
|
|
769
|
+
else if (arg === "--discord-user-id") flags.discordUserId = requireValue(copy, ++i, arg);
|
|
424
770
|
else if (arg === "--user-id") flags.userId = requireValue(copy, ++i, arg);
|
|
425
771
|
}
|
|
426
772
|
return flags;
|
|
@@ -452,8 +798,8 @@ async function commandUser(options) {
|
|
|
452
798
|
? ["admin"]
|
|
453
799
|
: (flags.groups ? flags.groups.split(",").map((item) => item.trim()).filter(Boolean) : ["user"]);
|
|
454
800
|
const created = flags.subcommand === "create-admin"
|
|
455
|
-
? store.createAdmin({ email, displayName: name, password, telegramUserId: flags.telegramUserId })
|
|
456
|
-
: store.createUser({ email, displayName: name, password, groupIds, telegramUserId: flags.telegramUserId });
|
|
801
|
+
? store.createAdmin({ email, displayName: name, password, telegramUserId: flags.telegramUserId, discordUserId: flags.discordUserId })
|
|
802
|
+
: store.createUser({ email, displayName: name, password, groupIds, telegramUserId: flags.telegramUserId, discordUserId: flags.discordUserId });
|
|
457
803
|
console.log(`Created user ${created.user.email} (${created.groups.map((group) => group.name).join(", ")}).`);
|
|
458
804
|
return;
|
|
459
805
|
}
|
|
@@ -478,7 +824,17 @@ async function commandUser(options) {
|
|
|
478
824
|
return;
|
|
479
825
|
}
|
|
480
826
|
|
|
481
|
-
if (flags.subcommand === "link-
|
|
827
|
+
if (flags.subcommand === "link-discord") {
|
|
828
|
+
const email = flags.email || await ask(null, "Email", "");
|
|
829
|
+
const discordUserId = flags.discordUserId || await ask(null, "Discord user id", "");
|
|
830
|
+
const user = store.getUserByEmail(email);
|
|
831
|
+
if (!user) throw new Error(`User not found: ${email}`);
|
|
832
|
+
store.linkDiscordUser(user.user.id, { discordUserId });
|
|
833
|
+
console.log(`Linked Discord user ${discordUserId} to ${user.user.email}.`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (flags.subcommand === "link-code" || flags.subcommand === "telegram-link-code") {
|
|
482
838
|
const email = flags.email || await ask(null, "Email", "");
|
|
483
839
|
const user = store.getUserByEmail(email);
|
|
484
840
|
if (!user) throw new Error(`User not found: ${email}`);
|
|
@@ -488,7 +844,17 @@ async function commandUser(options) {
|
|
|
488
844
|
return;
|
|
489
845
|
}
|
|
490
846
|
|
|
491
|
-
|
|
847
|
+
if (flags.subcommand === "discord-link-code") {
|
|
848
|
+
const email = flags.email || await ask(null, "Email", "");
|
|
849
|
+
const user = store.getUserByEmail(email);
|
|
850
|
+
if (!user) throw new Error(`User not found: ${email}`);
|
|
851
|
+
const code = store.createDiscordLinkCode(user.user.id);
|
|
852
|
+
console.log(`Discord link code for ${user.user.email}: ${code.code}`);
|
|
853
|
+
console.log(`Expires: ${code.expiresAt}`);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
throw new Error("Usage: nordrelay user [list|create-admin|create|reset-password|link-telegram|link-discord|link-code|telegram-link-code|discord-link-code]");
|
|
492
858
|
}
|
|
493
859
|
|
|
494
860
|
async function commandDoctor(options) {
|
|
@@ -498,11 +864,16 @@ async function commandDoctor(options) {
|
|
|
498
864
|
const userSnapshot = userStore?.snapshot();
|
|
499
865
|
const checks = [];
|
|
500
866
|
checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
|
|
501
|
-
|
|
867
|
+
const telegramEnabled = process.env.TELEGRAM_ENABLED !== "false";
|
|
868
|
+
const discordEnabled = process.env.DISCORD_ENABLED === "true";
|
|
869
|
+
checks.push(check("Telegram bot token", !telegramEnabled || Boolean(process.env.TELEGRAM_BOT_TOKEN), telegramEnabled ? (process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing") : "disabled", telegramEnabled ? "fail" : "warn"));
|
|
870
|
+
checks.push(check("Discord bot token", !discordEnabled || Boolean(process.env.DISCORD_BOT_TOKEN), discordEnabled ? (process.env.DISCORD_BOT_TOKEN ? "configured" : "missing") : "disabled", discordEnabled ? "fail" : "warn"));
|
|
871
|
+
checks.push(check("Discord client ID", !discordEnabled || Boolean(process.env.DISCORD_CLIENT_ID), discordEnabled ? (process.env.DISCORD_CLIENT_ID ? "configured" : "missing; slash command auto-registration disabled") : "disabled", "warn"));
|
|
502
872
|
checks.push(check("User store", Boolean(userStore), userStore ? userStore.filePath : "missing runtime", userStore ? "pass" : "fail"));
|
|
503
873
|
checks.push(check("Admin user", Boolean(userSnapshot?.adminConfigured), userSnapshot?.adminConfigured ? "configured" : "missing"));
|
|
504
874
|
checks.push(check("WebUI login", true, "required for every dashboard request"));
|
|
505
875
|
checks.push(check("Telegram access", true, "requires linked active users and enabled group chats"));
|
|
876
|
+
checks.push(check("Discord access", true, "requires linked active users and enabled channels"));
|
|
506
877
|
checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
|
|
507
878
|
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"));
|
|
508
879
|
checks.push(check("Hermes enabled flag", process.env.NORDRELAY_HERMES_ENABLED === "true", `NORDRELAY_HERMES_ENABLED=${process.env.NORDRELAY_HERMES_ENABLED ?? "false"}`, process.env.NORDRELAY_HERMES_ENABLED === "true" ? "pass" : "warn"));
|
|
@@ -607,8 +978,21 @@ async function checkOpenClawGateway() {
|
|
|
607
978
|
async function commandWeb(options) {
|
|
608
979
|
await mkdirp(options.home);
|
|
609
980
|
loadEnvFiles(options.home);
|
|
610
|
-
const { host, port } = resolveDashboardEndpoint(options, { strict: true });
|
|
611
981
|
await ensureConnectorStartedForWeb(options);
|
|
982
|
+
await startWebDashboard(options, { detached: false });
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function startWebDashboard(options, settings = {}) {
|
|
986
|
+
await mkdirp(options.home);
|
|
987
|
+
loadEnvFiles(options.home);
|
|
988
|
+
const { host, port } = resolveDashboardEndpoint(options, { strict: true });
|
|
989
|
+
const currentPid = await readWebPid(options);
|
|
990
|
+
if (isProcessRunning(currentPid)) {
|
|
991
|
+
console.log(`NordRelay dashboard already running with PID ${currentPid}.`);
|
|
992
|
+
console.log(`NordRelay dashboard: ${formatDashboardUrl({ host, port })}`);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
await fsp.rm(options.webPidFile, { force: true });
|
|
612
996
|
const entry = await resolveWebRuntimeEntry();
|
|
613
997
|
if (!entry) {
|
|
614
998
|
throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
|
|
@@ -621,12 +1005,43 @@ async function commandWeb(options) {
|
|
|
621
1005
|
NORDRELAY_DASHBOARD_HOST: host,
|
|
622
1006
|
NORDRELAY_DASHBOARD_PORT: String(port),
|
|
623
1007
|
};
|
|
1008
|
+
await writeWebState(options, {
|
|
1009
|
+
status: "starting",
|
|
1010
|
+
pid: null,
|
|
1011
|
+
host,
|
|
1012
|
+
port,
|
|
1013
|
+
url: formatDashboardUrl({ host, port }),
|
|
1014
|
+
});
|
|
1015
|
+
const stdio = settings.detached
|
|
1016
|
+
? ["ignore", fs.openSync(options.webLogFile, "a"), fs.openSync(options.webLogFile, "a")]
|
|
1017
|
+
: "inherit";
|
|
624
1018
|
const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
|
|
625
1019
|
cwd: RUNTIME_ROOT,
|
|
626
1020
|
env,
|
|
627
|
-
|
|
1021
|
+
detached: Boolean(settings.detached),
|
|
1022
|
+
stdio,
|
|
1023
|
+
});
|
|
1024
|
+
await fsp.writeFile(options.webPidFile, `${child.pid}\n`);
|
|
1025
|
+
await writeWebState(options, {
|
|
1026
|
+
status: "running",
|
|
1027
|
+
pid: child.pid,
|
|
1028
|
+
host,
|
|
1029
|
+
port,
|
|
1030
|
+
url: formatDashboardUrl({ host, port }),
|
|
628
1031
|
});
|
|
629
1032
|
|
|
1033
|
+
if (settings.detached) {
|
|
1034
|
+
child.unref();
|
|
1035
|
+
if (Array.isArray(stdio)) {
|
|
1036
|
+
fs.closeSync(stdio[1]);
|
|
1037
|
+
fs.closeSync(stdio[2]);
|
|
1038
|
+
}
|
|
1039
|
+
console.log(`NordRelay dashboard started with PID ${child.pid}.`);
|
|
1040
|
+
console.log(`NordRelay dashboard: ${formatDashboardUrl({ host, port })}`);
|
|
1041
|
+
console.log(`WebUI log: ${options.webLogFile}`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
630
1045
|
const forwardSignal = (signal) => {
|
|
631
1046
|
if (isProcessRunning(child.pid)) {
|
|
632
1047
|
child.kill(signal);
|
|
@@ -635,9 +1050,23 @@ async function commandWeb(options) {
|
|
|
635
1050
|
process.once("SIGINT", () => forwardSignal("SIGINT"));
|
|
636
1051
|
process.once("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
637
1052
|
|
|
638
|
-
const exit = await new Promise((resolve) => {
|
|
1053
|
+
const exit = await new Promise((resolve, reject) => {
|
|
1054
|
+
child.once("error", reject);
|
|
639
1055
|
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
640
1056
|
});
|
|
1057
|
+
const pid = await readWebPid(options);
|
|
1058
|
+
if (pid === child.pid) {
|
|
1059
|
+
await fsp.rm(options.webPidFile, { force: true });
|
|
1060
|
+
}
|
|
1061
|
+
await writeWebState(options, {
|
|
1062
|
+
status: exit.code === 0 ? "stopped" : "error",
|
|
1063
|
+
pid: null,
|
|
1064
|
+
host,
|
|
1065
|
+
port,
|
|
1066
|
+
url: formatDashboardUrl({ host, port }),
|
|
1067
|
+
exitCode: exit.code,
|
|
1068
|
+
signal: exit.signal,
|
|
1069
|
+
});
|
|
641
1070
|
if (exit.signal) {
|
|
642
1071
|
process.kill(process.pid, exit.signal);
|
|
643
1072
|
return;
|
|
@@ -858,17 +1287,26 @@ function check(name, ok, detail, status = "fail") {
|
|
|
858
1287
|
};
|
|
859
1288
|
}
|
|
860
1289
|
|
|
861
|
-
function findExecutable(command) {
|
|
1290
|
+
function findExecutable(command, pathValue = process.env.PATH, pathextValue = process.env.PATHEXT) {
|
|
862
1291
|
if (!command) return null;
|
|
863
1292
|
if (command.includes(path.sep) && fs.existsSync(command)) return command;
|
|
864
|
-
const paths = (
|
|
1293
|
+
const paths = (pathValue || "").split(path.delimiter);
|
|
1294
|
+
const extensions = process.platform === "win32"
|
|
1295
|
+
? ["", ...(pathextValue || ".COM;.EXE;.BAT;.CMD").split(";")]
|
|
1296
|
+
: [""];
|
|
865
1297
|
for (const dir of paths) {
|
|
866
|
-
const
|
|
867
|
-
|
|
1298
|
+
for (const extension of extensions) {
|
|
1299
|
+
const candidate = path.join(dir, `${command}${extension}`);
|
|
1300
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
1301
|
+
}
|
|
868
1302
|
}
|
|
869
1303
|
return null;
|
|
870
1304
|
}
|
|
871
1305
|
|
|
1306
|
+
function isWindowsShellScript(filePath) {
|
|
1307
|
+
return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
872
1310
|
function validateStateBackend() {
|
|
873
1311
|
const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
|
|
874
1312
|
if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
|
|
@@ -920,10 +1358,18 @@ async function main() {
|
|
|
920
1358
|
if (options.command === "init") return commandInit(options);
|
|
921
1359
|
if (options.command === "user") return commandUser(options);
|
|
922
1360
|
if (options.command === "doctor") return commandDoctor(options);
|
|
1361
|
+
if (options.command === "update") return commandUpdate(options);
|
|
923
1362
|
if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
|
|
924
1363
|
if (options.command === "restart") {
|
|
1364
|
+
await mkdirp(options.home);
|
|
1365
|
+
loadEnvFiles(options.home);
|
|
1366
|
+
const webWasRunning = await isWebDashboardRunning(options);
|
|
925
1367
|
await commandStop(options);
|
|
926
|
-
|
|
1368
|
+
await commandStart(options);
|
|
1369
|
+
if (webWasRunning && process.exitCode !== 1) {
|
|
1370
|
+
await startWebDashboard(options, { detached: true });
|
|
1371
|
+
}
|
|
1372
|
+
return;
|
|
927
1373
|
}
|
|
928
1374
|
if (options.command === "foreground") return commandForeground(options);
|
|
929
1375
|
if (options.command === "--version" || options.command === "version") {
|
|
@@ -932,7 +1378,7 @@ async function main() {
|
|
|
932
1378
|
}
|
|
933
1379
|
|
|
934
1380
|
console.error(`Unknown command: ${options.command}`);
|
|
935
|
-
console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|foreground|version]");
|
|
1381
|
+
console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|update|foreground|version]");
|
|
936
1382
|
process.exitCode = 2;
|
|
937
1383
|
}
|
|
938
1384
|
|