@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.
Files changed (57) hide show
  1. package/.env.example +65 -11
  2. package/README.md +97 -23
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/agent-updates.js +18 -2
  6. package/dist/audit-log.js +40 -2
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +492 -7
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +34 -7
  11. package/dist/channel-command-service.js +156 -0
  12. package/dist/channel-turn-service.js +237 -0
  13. package/dist/codex-cli.js +1 -1
  14. package/dist/config-metadata.js +80 -13
  15. package/dist/config.js +77 -7
  16. package/dist/context-key.js +77 -5
  17. package/dist/discord-artifacts.js +165 -0
  18. package/dist/discord-bot.js +2014 -0
  19. package/dist/discord-channel-runtime.js +133 -0
  20. package/dist/discord-command-surface.js +119 -0
  21. package/dist/discord-rate-limit.js +141 -0
  22. package/dist/index.js +16 -5
  23. package/dist/job-store.js +127 -0
  24. package/dist/metrics.js +41 -0
  25. package/dist/operations.js +176 -119
  26. package/dist/relay-external-activity-monitor.js +47 -6
  27. package/dist/relay-runtime.js +1003 -268
  28. package/dist/runtime-cache.js +57 -0
  29. package/dist/session-locks.js +10 -7
  30. package/dist/state-backend.js +3 -0
  31. package/dist/support-bundle.js +18 -1
  32. package/dist/telegram-access-commands.js +15 -2
  33. package/dist/telegram-access-middleware.js +16 -3
  34. package/dist/telegram-agent-commands.js +25 -0
  35. package/dist/telegram-artifact-commands.js +46 -0
  36. package/dist/telegram-diagnostics-command.js +5 -50
  37. package/dist/telegram-general-commands.js +2 -6
  38. package/dist/telegram-operational-commands.js +14 -6
  39. package/dist/telegram-queue-commands.js +74 -4
  40. package/dist/telegram-support-command.js +7 -0
  41. package/dist/telegram-update-commands.js +27 -0
  42. package/dist/user-management.js +208 -0
  43. package/dist/web-api-contract.js +9 -0
  44. package/dist/web-dashboard-access-routes.js +74 -1
  45. package/dist/web-dashboard-artifact-routes.js +3 -3
  46. package/dist/web-dashboard-assets.js +2 -0
  47. package/dist/web-dashboard-pages.js +97 -13
  48. package/dist/web-dashboard-runtime-routes.js +53 -8
  49. package/dist/web-dashboard-session-routes.js +27 -20
  50. package/dist/web-dashboard-ui.js +1 -0
  51. package/dist/web-dashboard.js +149 -6
  52. package/dist/web-state.js +33 -2
  53. package/dist/webui-assets/dashboard.css +75 -1
  54. package/dist/webui-assets/dashboard.js +358 -47
  55. package/package.json +3 -1
  56. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  57. 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
- console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
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
- console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
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 commandStop(options) {
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 telegramBotToken = options.telegramBotToken ||
340
- process.env.TELEGRAM_BOT_TOKEN ||
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-code") {
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
- throw new Error("Usage: nordrelay user [list|create-admin|create|reset-password|link-telegram|link-code]");
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
- checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
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
- stdio: "inherit",
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 = (process.env.PATH || "").split(path.delimiter);
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 candidate = path.join(dir, command);
867
- if (fs.existsSync(candidate)) return candidate;
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
- return commandStart(options);
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