@nordbyte/nordrelay 0.5.2 → 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 (52) hide show
  1. package/.env.example +63 -11
  2. package/README.md +90 -19
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-rendering.js +10 -7
  7. package/dist/bot.js +458 -5
  8. package/dist/channel-actions.js +7 -2
  9. package/dist/channel-adapter.js +34 -7
  10. package/dist/channel-command-service.js +156 -0
  11. package/dist/channel-turn-service.js +237 -0
  12. package/dist/config-metadata.js +78 -13
  13. package/dist/config.js +77 -7
  14. package/dist/context-key.js +77 -5
  15. package/dist/discord-artifacts.js +165 -0
  16. package/dist/discord-bot.js +2014 -0
  17. package/dist/discord-channel-runtime.js +133 -0
  18. package/dist/discord-command-surface.js +119 -0
  19. package/dist/discord-rate-limit.js +141 -0
  20. package/dist/index.js +16 -5
  21. package/dist/job-store.js +127 -0
  22. package/dist/metrics.js +41 -0
  23. package/dist/relay-external-activity-monitor.js +47 -6
  24. package/dist/relay-runtime.js +986 -281
  25. package/dist/runtime-cache.js +57 -0
  26. package/dist/session-locks.js +10 -7
  27. package/dist/support-bundle.js +1 -0
  28. package/dist/telegram-access-commands.js +15 -2
  29. package/dist/telegram-access-middleware.js +16 -3
  30. package/dist/telegram-agent-commands.js +25 -0
  31. package/dist/telegram-artifact-commands.js +46 -0
  32. package/dist/telegram-diagnostics-command.js +5 -50
  33. package/dist/telegram-general-commands.js +2 -6
  34. package/dist/telegram-operational-commands.js +14 -6
  35. package/dist/telegram-queue-commands.js +74 -4
  36. package/dist/telegram-support-command.js +7 -0
  37. package/dist/telegram-update-commands.js +27 -0
  38. package/dist/user-management.js +208 -0
  39. package/dist/web-api-contract.js +9 -0
  40. package/dist/web-dashboard-access-routes.js +74 -1
  41. package/dist/web-dashboard-artifact-routes.js +3 -3
  42. package/dist/web-dashboard-assets.js +2 -0
  43. package/dist/web-dashboard-pages.js +97 -13
  44. package/dist/web-dashboard-runtime-routes.js +53 -8
  45. package/dist/web-dashboard-session-routes.js +27 -20
  46. package/dist/web-dashboard-ui.js +1 -0
  47. package/dist/web-dashboard.js +148 -6
  48. package/dist/web-state.js +33 -2
  49. package/dist/webui-assets/dashboard.css +75 -1
  50. package/dist/webui-assets/dashboard.js +358 -47
  51. package/package.json +3 -1
  52. package/plugins/nordrelay/scripts/nordrelay.mjs +210 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -23,6 +23,7 @@
23
23
  "claude-code",
24
24
  "claude",
25
25
  "telegram",
26
+ "discord",
26
27
  "bot",
27
28
  "automation"
28
29
  ],
@@ -63,6 +64,7 @@
63
64
  "@anthropic-ai/claude-agent-sdk": "^0.2.140",
64
65
  "@grammyjs/auto-retry": "^2.0.2",
65
66
  "@openai/codex-sdk": "^0.130.0",
67
+ "discord.js": "^14.26.4",
66
68
  "grammy": "^1.41.1",
67
69
  "zod": "^4.4.3"
68
70
  },
@@ -66,6 +66,10 @@ function parseArgs(argv) {
66
66
  else if (arg === "--no-restart") options.restartAfterUpdate = false;
67
67
  else if (arg === "--restart") options.restartAfterUpdate = true;
68
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);
69
73
  else if (arg === "--admin-email") options.adminEmail = requireValue(copy, ++i, arg);
70
74
  else if (arg === "--admin-name") options.adminName = requireValue(copy, ++i, arg);
71
75
  else if (arg === "--admin-password") options.adminPassword = requireValue(copy, ++i, arg);
@@ -81,6 +85,9 @@ function parseArgs(argv) {
81
85
  options.pidFile = path.join(options.home, "nordrelay.pid");
82
86
  options.stateFile = path.join(options.home, "state.json");
83
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");
84
91
  return options;
85
92
  }
86
93
 
@@ -175,6 +182,26 @@ async function readPid(pidFile) {
175
182
  }
176
183
  }
177
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
+
178
205
  function resolveDashboardEndpoint(options, settings = {}) {
179
206
  const host = options.host || process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
180
207
  const rawPort = options.port ?? Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
@@ -233,7 +260,9 @@ async function commandStart(options, settings = {}) {
233
260
  console.log(`Workspace: ${state.workspace || "-"}`);
234
261
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
235
262
  if (!settings.skipWebHint) {
236
- 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}`);
237
266
  }
238
267
  console.log(`Log: ${options.logFile}`);
239
268
  return;
@@ -251,7 +280,9 @@ async function commandStart(options, settings = {}) {
251
280
 
252
281
  console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
253
282
  if (!settings.skipWebHint) {
254
- 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}`);
255
286
  }
256
287
  console.log(`Startup is still in progress. Log: ${options.logFile}`);
257
288
  }
@@ -286,7 +317,51 @@ async function waitForState(stateFile, pid, timeoutMs) {
286
317
  return await readJson(stateFile);
287
318
  }
288
319
 
289
- 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
+ }
290
365
  const pid = await readPid(options.pidFile);
291
366
  if (!isProcessRunning(pid)) {
292
367
  console.log("Connector is not running.");
@@ -313,10 +388,19 @@ async function commandStatus(options) {
313
388
  loadEnvFiles(options.home);
314
389
  const dashboard = resolveDashboardEndpoint(options);
315
390
  const pid = await readPid(options.pidFile);
391
+ const webPid = await readWebPid(options);
316
392
  const state = await readJson(options.stateFile, {});
393
+ const webState = await readWebState(options);
317
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
+ }
318
401
  console.log(`Status: ${state.status || (running ? "running" : "stopped")}`);
319
402
  console.log(`PID: ${pid || "-"} (${running ? "running" : "not running"})`);
403
+ console.log(`WebUI PID: ${webPid || "-"} (${webRunning ? "running" : "not running"})`);
320
404
  console.log(`Workspace: ${state.workspace || "-"}`);
321
405
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
322
406
  console.log(`Auth: ${state.authenticated === undefined ? "-" : state.authenticated ? "yes" : "no"}`);
@@ -326,8 +410,9 @@ async function commandStatus(options) {
326
410
  console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
327
411
  console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
328
412
  console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
329
- console.log(`WebUI: ${formatDashboardUrl(dashboard)}`);
413
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} (${webStatus})`);
330
414
  console.log(`Log: ${options.logFile}`);
415
+ console.log(`WebUI log: ${options.webLogFile}`);
331
416
  if (state.error) console.log(`Error: ${state.error}`);
332
417
  }
333
418
 
@@ -579,9 +664,17 @@ async function commandInit(options) {
579
664
  return;
580
665
  }
581
666
 
582
- const telegramBotToken = options.telegramBotToken ||
583
- process.env.TELEGRAM_BOT_TOKEN ||
584
- 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
+ : "";
585
678
  const adminEmail = options.adminEmail || await ask(null, "Admin email", "");
586
679
  const adminName = options.adminName || await ask(null, "Admin name", "Admin");
587
680
  const adminPassword = options.adminPassword || await askSecret(null, "Admin password", "");
@@ -593,7 +686,9 @@ async function commandInit(options) {
593
686
  const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(null, "Enable Claude Code", "false");
594
687
  const stateBackend = options.stateBackend || await askChoice(null, "State backend (json/sqlite)", "json");
595
688
 
596
- 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.");
597
692
  if (!adminEmail) throw new Error("Admin email is required.");
598
693
  if (!adminPassword) throw new Error("Admin password is required.");
599
694
  if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
@@ -610,7 +705,14 @@ async function commandInit(options) {
610
705
  const lines = [
611
706
  "# NordRelay local runtime config.",
612
707
  "# Keep this file private; it contains bot credentials.",
708
+ `TELEGRAM_ENABLED=${enableTelegram}`,
613
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",
614
716
  `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
615
717
  `NORDRELAY_PI_ENABLED=${enablePi}`,
616
718
  `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
@@ -664,6 +766,7 @@ function parseUserFlags(argv) {
664
766
  else if (arg === "--password") flags.password = requireValue(copy, ++i, arg);
665
767
  else if (arg === "--group" || arg === "--groups") flags.groups = requireValue(copy, ++i, arg);
666
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);
667
770
  else if (arg === "--user-id") flags.userId = requireValue(copy, ++i, arg);
668
771
  }
669
772
  return flags;
@@ -695,8 +798,8 @@ async function commandUser(options) {
695
798
  ? ["admin"]
696
799
  : (flags.groups ? flags.groups.split(",").map((item) => item.trim()).filter(Boolean) : ["user"]);
697
800
  const created = flags.subcommand === "create-admin"
698
- ? store.createAdmin({ email, displayName: name, password, telegramUserId: flags.telegramUserId })
699
- : 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 });
700
803
  console.log(`Created user ${created.user.email} (${created.groups.map((group) => group.name).join(", ")}).`);
701
804
  return;
702
805
  }
@@ -721,7 +824,17 @@ async function commandUser(options) {
721
824
  return;
722
825
  }
723
826
 
724
- 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") {
725
838
  const email = flags.email || await ask(null, "Email", "");
726
839
  const user = store.getUserByEmail(email);
727
840
  if (!user) throw new Error(`User not found: ${email}`);
@@ -731,7 +844,17 @@ async function commandUser(options) {
731
844
  return;
732
845
  }
733
846
 
734
- 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]");
735
858
  }
736
859
 
737
860
  async function commandDoctor(options) {
@@ -741,11 +864,16 @@ async function commandDoctor(options) {
741
864
  const userSnapshot = userStore?.snapshot();
742
865
  const checks = [];
743
866
  checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
744
- 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"));
745
872
  checks.push(check("User store", Boolean(userStore), userStore ? userStore.filePath : "missing runtime", userStore ? "pass" : "fail"));
746
873
  checks.push(check("Admin user", Boolean(userSnapshot?.adminConfigured), userSnapshot?.adminConfigured ? "configured" : "missing"));
747
874
  checks.push(check("WebUI login", true, "required for every dashboard request"));
748
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"));
749
877
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
750
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"));
751
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"));
@@ -850,8 +978,21 @@ async function checkOpenClawGateway() {
850
978
  async function commandWeb(options) {
851
979
  await mkdirp(options.home);
852
980
  loadEnvFiles(options.home);
853
- const { host, port } = resolveDashboardEndpoint(options, { strict: true });
854
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 });
855
996
  const entry = await resolveWebRuntimeEntry();
856
997
  if (!entry) {
857
998
  throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
@@ -864,12 +1005,43 @@ async function commandWeb(options) {
864
1005
  NORDRELAY_DASHBOARD_HOST: host,
865
1006
  NORDRELAY_DASHBOARD_PORT: String(port),
866
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";
867
1018
  const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
868
1019
  cwd: RUNTIME_ROOT,
869
1020
  env,
870
- 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 }),
871
1031
  });
872
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
+
873
1045
  const forwardSignal = (signal) => {
874
1046
  if (isProcessRunning(child.pid)) {
875
1047
  child.kill(signal);
@@ -878,9 +1050,23 @@ async function commandWeb(options) {
878
1050
  process.once("SIGINT", () => forwardSignal("SIGINT"));
879
1051
  process.once("SIGTERM", () => forwardSignal("SIGTERM"));
880
1052
 
881
- const exit = await new Promise((resolve) => {
1053
+ const exit = await new Promise((resolve, reject) => {
1054
+ child.once("error", reject);
882
1055
  child.once("exit", (code, signal) => resolve({ code, signal }));
883
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
+ });
884
1070
  if (exit.signal) {
885
1071
  process.kill(process.pid, exit.signal);
886
1072
  return;
@@ -1175,8 +1361,15 @@ async function main() {
1175
1361
  if (options.command === "update") return commandUpdate(options);
1176
1362
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
1177
1363
  if (options.command === "restart") {
1364
+ await mkdirp(options.home);
1365
+ loadEnvFiles(options.home);
1366
+ const webWasRunning = await isWebDashboardRunning(options);
1178
1367
  await commandStop(options);
1179
- return commandStart(options);
1368
+ await commandStart(options);
1369
+ if (webWasRunning && process.exitCode !== 1) {
1370
+ await startWebDashboard(options, { detached: true });
1371
+ }
1372
+ return;
1180
1373
  }
1181
1374
  if (options.command === "foreground") return commandForeground(options);
1182
1375
  if (options.command === "--version" || options.command === "version") {