@nordbyte/nordrelay 0.5.2 → 0.7.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 (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -35,6 +35,10 @@ function readRuntimePackageVersion() {
35
35
  function parseArgs(argv) {
36
36
  const copy = [...argv];
37
37
  let command = "foreground";
38
+ if (copy[0] === "--help" || copy[0] === "-h") {
39
+ command = "help";
40
+ copy.shift();
41
+ }
38
42
  if (copy[0] === "--version" || copy[0] === "-v") {
39
43
  command = "version";
40
44
  copy.shift();
@@ -53,6 +57,7 @@ function parseArgs(argv) {
53
57
  port: undefined,
54
58
  restartAfterUpdate: true,
55
59
  updateMethod: undefined,
60
+ buildBeforeStart: false,
56
61
  };
57
62
 
58
63
  for (let i = 0; i < copy.length; i += 1) {
@@ -63,9 +68,14 @@ function parseArgs(argv) {
63
68
  else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
64
69
  else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
65
70
  else if (arg === "--method") options.updateMethod = requireValue(copy, ++i, arg);
71
+ else if (arg === "--build") options.buildBeforeStart = true;
66
72
  else if (arg === "--no-restart") options.restartAfterUpdate = false;
67
73
  else if (arg === "--restart") options.restartAfterUpdate = true;
68
74
  else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
75
+ else if (arg === "--disable-telegram") options.disableTelegram = true;
76
+ else if (arg === "--enable-discord") options.enableDiscord = true;
77
+ else if (arg === "--discord-token") options.discordBotToken = requireValue(copy, ++i, arg);
78
+ else if (arg === "--discord-client-id") options.discordClientId = requireValue(copy, ++i, arg);
69
79
  else if (arg === "--admin-email") options.adminEmail = requireValue(copy, ++i, arg);
70
80
  else if (arg === "--admin-name") options.adminName = requireValue(copy, ++i, arg);
71
81
  else if (arg === "--admin-password") options.adminPassword = requireValue(copy, ++i, arg);
@@ -81,6 +91,9 @@ function parseArgs(argv) {
81
91
  options.pidFile = path.join(options.home, "nordrelay.pid");
82
92
  options.stateFile = path.join(options.home, "state.json");
83
93
  options.logFile = path.join(options.home, "nordrelay.log");
94
+ options.webPidFile = path.join(options.home, "nordrelay-web.pid");
95
+ options.webStateFile = path.join(options.home, "web-state.json");
96
+ options.webLogFile = path.join(options.home, "nordrelay-web.log");
84
97
  return options;
85
98
  }
86
99
 
@@ -175,6 +188,26 @@ async function readPid(pidFile) {
175
188
  }
176
189
  }
177
190
 
191
+ async function readWebState(options) {
192
+ return await readJson(options.webStateFile, {});
193
+ }
194
+
195
+ async function readWebPid(options) {
196
+ return await readPid(options.webPidFile);
197
+ }
198
+
199
+ async function isWebDashboardRunning(options) {
200
+ return isProcessRunning(await readWebPid(options));
201
+ }
202
+
203
+ async function writeWebState(options, patch) {
204
+ await writeJsonAtomic(options.webStateFile, {
205
+ updatedAt: nowIso(),
206
+ logFile: options.webLogFile,
207
+ ...patch,
208
+ });
209
+ }
210
+
178
211
  function resolveDashboardEndpoint(options, settings = {}) {
179
212
  const host = options.host || process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
180
213
  const rawPort = options.port ?? Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
@@ -199,6 +232,7 @@ function formatDashboardUrl(endpoint) {
199
232
  async function commandStart(options, settings = {}) {
200
233
  await mkdirp(options.home);
201
234
  loadEnvFiles(options.home);
235
+ await prepareRuntimeForLaunch(options);
202
236
  const dashboard = resolveDashboardEndpoint(options);
203
237
 
204
238
  const currentPid = await readPid(options.pidFile);
@@ -216,7 +250,7 @@ async function commandStart(options, settings = {}) {
216
250
  });
217
251
 
218
252
  const logFd = fs.openSync(options.logFile, "a");
219
- const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...options.rawFlags], {
253
+ const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...runtimeForwardFlags(options.rawFlags)], {
220
254
  cwd: RUNTIME_ROOT,
221
255
  detached: true,
222
256
  env: process.env,
@@ -233,7 +267,9 @@ async function commandStart(options, settings = {}) {
233
267
  console.log(`Workspace: ${state.workspace || "-"}`);
234
268
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
235
269
  if (!settings.skipWebHint) {
236
- console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
270
+ const webPid = await readWebPid(options);
271
+ const webHint = isProcessRunning(webPid) ? `(running with PID ${webPid})` : "(run `nordrelay web` to start it)";
272
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} ${webHint}`);
237
273
  }
238
274
  console.log(`Log: ${options.logFile}`);
239
275
  return;
@@ -251,7 +287,9 @@ async function commandStart(options, settings = {}) {
251
287
 
252
288
  console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
253
289
  if (!settings.skipWebHint) {
254
- console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
290
+ const webPid = await readWebPid(options);
291
+ const webHint = isProcessRunning(webPid) ? `(running with PID ${webPid})` : "(run `nordrelay web` to start it)";
292
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} ${webHint}`);
255
293
  }
256
294
  console.log(`Startup is still in progress. Log: ${options.logFile}`);
257
295
  }
@@ -286,7 +324,51 @@ async function waitForState(stateFile, pid, timeoutMs) {
286
324
  return await readJson(stateFile);
287
325
  }
288
326
 
289
- async function commandStop(options) {
327
+ async function stopWebDashboard(options, settings = {}) {
328
+ const pid = await readWebPid(options);
329
+ if (!isProcessRunning(pid)) {
330
+ await fsp.rm(options.webPidFile, { force: true });
331
+ const state = await readWebState(options);
332
+ if (state.status === "running" || state.status === "starting") {
333
+ await writeWebState(options, { status: "stopped", pid: null });
334
+ }
335
+ if (!settings.quiet) {
336
+ console.log("WebUI is not running.");
337
+ }
338
+ return false;
339
+ }
340
+
341
+ process.kill(pid, "SIGTERM");
342
+ for (let i = 0; i < 40; i += 1) {
343
+ if (!isProcessRunning(pid)) break;
344
+ await sleep(250);
345
+ }
346
+
347
+ if (isProcessRunning(pid)) {
348
+ process.kill(pid, "SIGKILL");
349
+ for (let i = 0; i < 20; i += 1) {
350
+ if (!isProcessRunning(pid)) break;
351
+ await sleep(250);
352
+ }
353
+ }
354
+
355
+ if (isProcessRunning(pid)) {
356
+ console.log(`WebUI PID ${pid} did not exit after SIGTERM/SIGKILL.`);
357
+ process.exitCode = 1;
358
+ return false;
359
+ }
360
+ await fsp.rm(options.webPidFile, { force: true });
361
+ await writeWebState(options, { status: "stopped", pid: null });
362
+ if (!settings.quiet) {
363
+ console.log(`Stopped WebUI PID ${pid}.`);
364
+ }
365
+ return true;
366
+ }
367
+
368
+ async function commandStop(options, settings = {}) {
369
+ if (!settings.keepWeb) {
370
+ await stopWebDashboard(options);
371
+ }
290
372
  const pid = await readPid(options.pidFile);
291
373
  if (!isProcessRunning(pid)) {
292
374
  console.log("Connector is not running.");
@@ -313,10 +395,19 @@ async function commandStatus(options) {
313
395
  loadEnvFiles(options.home);
314
396
  const dashboard = resolveDashboardEndpoint(options);
315
397
  const pid = await readPid(options.pidFile);
398
+ const webPid = await readWebPid(options);
316
399
  const state = await readJson(options.stateFile, {});
400
+ const webState = await readWebState(options);
317
401
  const running = isProcessRunning(pid);
402
+ const webRunning = isProcessRunning(webPid);
403
+ const webStatus = webRunning ? "running" : webState.status === "running" || webState.status === "starting" ? "stale" : webState.status || "stopped";
404
+ if (!webRunning && (webState.status === "running" || webState.status === "starting")) {
405
+ await fsp.rm(options.webPidFile, { force: true });
406
+ await writeWebState(options, { status: "stopped", pid: null });
407
+ }
318
408
  console.log(`Status: ${state.status || (running ? "running" : "stopped")}`);
319
409
  console.log(`PID: ${pid || "-"} (${running ? "running" : "not running"})`);
410
+ console.log(`WebUI PID: ${webPid || "-"} (${webRunning ? "running" : "not running"})`);
320
411
  console.log(`Workspace: ${state.workspace || "-"}`);
321
412
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
322
413
  console.log(`Auth: ${state.authenticated === undefined ? "-" : state.authenticated ? "yes" : "no"}`);
@@ -326,8 +417,11 @@ async function commandStatus(options) {
326
417
  console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
327
418
  console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
328
419
  console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
329
- console.log(`WebUI: ${formatDashboardUrl(dashboard)}`);
420
+ console.log(`Peers: ${state.peerEnabled ? state.peerUrl || "enabled" : "disabled"}`);
421
+ if (state.peerTlsFingerprint) console.log(`Peer TLS fingerprint: ${state.peerTlsFingerprint}`);
422
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} (${webStatus})`);
330
423
  console.log(`Log: ${options.logFile}`);
424
+ console.log(`WebUI log: ${options.webLogFile}`);
331
425
  if (state.error) console.log(`Error: ${state.error}`);
332
426
  }
333
427
 
@@ -579,9 +673,17 @@ async function commandInit(options) {
579
673
  return;
580
674
  }
581
675
 
582
- const telegramBotToken = options.telegramBotToken ||
583
- process.env.TELEGRAM_BOT_TOKEN ||
584
- await ask(null, "Telegram bot token", "");
676
+ const enableTelegram = options.disableTelegram ? "false" : await askChoice(null, "Enable Telegram", "true");
677
+ const telegramBotToken = enableTelegram === "true"
678
+ ? options.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || await ask(null, "Telegram bot token", "")
679
+ : "";
680
+ const enableDiscord = options.enableDiscord ? "true" : await askChoice(null, "Enable Discord", "false");
681
+ const discordBotToken = enableDiscord === "true"
682
+ ? options.discordBotToken || process.env.DISCORD_BOT_TOKEN || await ask(null, "Discord bot token", "")
683
+ : "";
684
+ const discordClientId = enableDiscord === "true"
685
+ ? options.discordClientId || process.env.DISCORD_CLIENT_ID || await ask(null, "Discord client ID", "")
686
+ : "";
585
687
  const adminEmail = options.adminEmail || await ask(null, "Admin email", "");
586
688
  const adminName = options.adminName || await ask(null, "Admin name", "Admin");
587
689
  const adminPassword = options.adminPassword || await askSecret(null, "Admin password", "");
@@ -593,7 +695,9 @@ async function commandInit(options) {
593
695
  const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(null, "Enable Claude Code", "false");
594
696
  const stateBackend = options.stateBackend || await askChoice(null, "State backend (json/sqlite)", "json");
595
697
 
596
- if (!telegramBotToken) throw new Error("Telegram bot token is required.");
698
+ if (enableTelegram === "true" && !telegramBotToken) throw new Error("Telegram bot token is required when Telegram is enabled.");
699
+ if (enableDiscord === "true" && !discordBotToken) throw new Error("Discord bot token is required when Discord is enabled.");
700
+ if (enableTelegram !== "true" && enableDiscord !== "true") throw new Error("At least one chat adapter must be enabled.");
597
701
  if (!adminEmail) throw new Error("Admin email is required.");
598
702
  if (!adminPassword) throw new Error("Admin password is required.");
599
703
  if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
@@ -610,7 +714,14 @@ async function commandInit(options) {
610
714
  const lines = [
611
715
  "# NordRelay local runtime config.",
612
716
  "# Keep this file private; it contains bot credentials.",
717
+ `TELEGRAM_ENABLED=${enableTelegram}`,
613
718
  `TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
719
+ `DISCORD_ENABLED=${enableDiscord}`,
720
+ `DISCORD_BOT_TOKEN=${discordBotToken}`,
721
+ `DISCORD_CLIENT_ID=${discordClientId}`,
722
+ "DISCORD_COMMAND_MODE=both",
723
+ "DISCORD_MESSAGE_CONTENT_ENABLED=true",
724
+ "DISCORD_AUTO_REGISTER_COMMANDS=true",
614
725
  `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
615
726
  `NORDRELAY_PI_ENABLED=${enablePi}`,
616
727
  `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
@@ -625,6 +736,11 @@ async function commandInit(options) {
625
736
  "OPENCLAW_DEFAULT_PROFILE=default",
626
737
  "CLAUDE_CODE_DEFAULT_PROFILE=default",
627
738
  "CLAUDE_CODE_MAX_TURNS=100",
739
+ "NORDRELAY_PEER_ENABLED=false",
740
+ "NORDRELAY_PEER_HOST=127.0.0.1",
741
+ "NORDRELAY_PEER_PORT=31979",
742
+ "NORDRELAY_PEER_TLS_ENABLED=true",
743
+ "NORDRELAY_PEER_REQUIRE_TLS=true",
628
744
  `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
629
745
  "TELEGRAM_TRANSPORT=polling",
630
746
  "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
@@ -653,6 +769,142 @@ async function createUserStore(home) {
653
769
  return new mod.UserStore(home);
654
770
  }
655
771
 
772
+ async function peerModules() {
773
+ const required = [
774
+ "peer-store.js",
775
+ "peer-identity.js",
776
+ "peer-client.js",
777
+ ];
778
+ for (const file of required) {
779
+ const modulePath = path.join(RUNTIME_ROOT, "dist", file);
780
+ if (!fs.existsSync(modulePath)) {
781
+ throw new Error(`Missing peer runtime. Run \`npm run build\` in ${RUNTIME_ROOT}.`);
782
+ }
783
+ }
784
+ const [store, identity, client] = await Promise.all(required.map((file) => import(pathToFileURL(path.join(RUNTIME_ROOT, "dist", file)).href)));
785
+ return { store, identity, client };
786
+ }
787
+
788
+ function parsePeerFlags(argv) {
789
+ const copy = [...argv];
790
+ const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
791
+ const flags = { subcommand, url: undefined };
792
+ if (["add", "test", "revoke"].includes(subcommand) && copy[0] && !copy[0].startsWith("-")) {
793
+ flags.url = copy.shift();
794
+ flags.id = flags.url;
795
+ }
796
+ for (let i = 0; i < copy.length; i += 1) {
797
+ const arg = copy[i];
798
+ if (arg === "--name") flags.name = requireValue(copy, ++i, arg);
799
+ else if (arg === "--code") flags.code = requireValue(copy, ++i, arg);
800
+ else if (arg === "--public-url") flags.publicUrl = requireValue(copy, ++i, arg);
801
+ else if (arg === "--expires" || arg === "--expires-minutes") flags.expiresMinutes = Number.parseInt(requireValue(copy, ++i, arg), 10);
802
+ else if (arg === "--scopes") flags.scopes = requireValue(copy, ++i, arg);
803
+ else if (arg === "--agents") flags.agents = requireValue(copy, ++i, arg);
804
+ else if (arg === "--workspaces") flags.workspaces = requireValue(copy, ++i, arg);
805
+ else if (arg === "--workspace-aliases" || arg === "--aliases") flags.workspaceAliases = requireValue(copy, ++i, arg);
806
+ }
807
+ return flags;
808
+ }
809
+
810
+ function csv(value) {
811
+ return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : undefined;
812
+ }
813
+
814
+ function aliasMap(value) {
815
+ if (!value) return undefined;
816
+ return Object.fromEntries(value.split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([alias, workspace]) => alias && workspace));
817
+ }
818
+
819
+ async function commandPeer(options) {
820
+ await mkdirp(options.home);
821
+ loadEnvFiles(options.home);
822
+ const flags = parsePeerFlags(options.rawFlags);
823
+ const { store: storeMod, identity: identityMod, client: clientMod } = await peerModules();
824
+ const store = new storeMod.PeerStore(options.home);
825
+ const identity = identityMod.loadOrCreatePeerIdentity(options.home, process.env.NORDRELAY_PEER_NAME);
826
+
827
+ if (flags.subcommand === "identity") {
828
+ console.log(`Node ID: ${identity.public.nodeId}`);
829
+ console.log(`Name: ${identity.public.name}`);
830
+ console.log(`Fingerprint: ${identity.public.fingerprint}`);
831
+ console.log(`Created: ${identity.public.createdAt}`);
832
+ return;
833
+ }
834
+
835
+ if (flags.subcommand === "list") {
836
+ const peers = store.listPublic();
837
+ if (peers.length === 0) {
838
+ console.log("No peers configured.");
839
+ console.log("Create an invite with `nordrelay peer invite` or add a peer with `nordrelay peer add <url> --code <code>`.");
840
+ return;
841
+ }
842
+ for (const peer of peers) {
843
+ console.log(`${peer.id} ${peer.enabled ? "enabled" : "disabled"} ${peer.name}`);
844
+ console.log(` URL: ${peer.url || "-"}`);
845
+ console.log(` Node: ${peer.nodeId} ${peer.fingerprint}`);
846
+ console.log(` Direction: ${peer.direction}`);
847
+ console.log(` Scopes: ${peer.scopes.join(",") || "-"}`);
848
+ console.log(` Agents: ${peer.allowedAgents.join(",") || "all"}`);
849
+ const aliases = Object.entries(peer.workspaceAliases || {}).map(([alias, workspace]) => `${alias}=${workspace}`).join(",");
850
+ if (aliases) console.log(` Workspace aliases: ${aliases}`);
851
+ if (peer.lastSeenAt) console.log(` Last seen: ${peer.lastSeenAt}`);
852
+ if (peer.lastLatencyMs !== undefined) console.log(` Latency: ${peer.lastLatencyMs}ms`);
853
+ if (peer.remoteVersion) console.log(` Remote version: ${peer.remoteVersion}`);
854
+ if (peer.lastError) console.log(` Last error: ${peer.lastError}`);
855
+ }
856
+ return;
857
+ }
858
+
859
+ if (flags.subcommand === "invite") {
860
+ const url = process.env.NORDRELAY_PEER_PUBLIC_URL || `${process.env.NORDRELAY_PEER_TLS_ENABLED === "false" ? "http" : "https"}://${process.env.NORDRELAY_PEER_HOST || "127.0.0.1"}:${process.env.NORDRELAY_PEER_PORT || "31979"}`;
861
+ const created = store.createInvitation({
862
+ name: flags.name,
863
+ expiresInMs: Number.isFinite(flags.expiresMinutes) ? flags.expiresMinutes * 60 * 1000 : undefined,
864
+ scopes: csv(flags.scopes),
865
+ allowedAgents: csv(flags.agents),
866
+ allowedWorkspaceRoots: csv(flags.workspaces),
867
+ workspaceAliases: aliasMap(flags.workspaceAliases),
868
+ });
869
+ console.log(`Pairing code: ${created.code}`);
870
+ console.log(`Expires: ${created.invitation.expiresAt}`);
871
+ console.log(`Fingerprint: ${identity.public.fingerprint}`);
872
+ console.log(`Command: nordrelay peer add ${url} --code ${created.code}`);
873
+ return;
874
+ }
875
+
876
+ if (flags.subcommand === "add") {
877
+ const url = flags.url || await ask(null, "Peer URL", "");
878
+ const code = flags.code || await ask(null, "Pairing code", "");
879
+ const result = await clientMod.pairPeer({
880
+ url,
881
+ code,
882
+ name: flags.name,
883
+ publicUrl: flags.publicUrl,
884
+ }, identity, store);
885
+ console.log(`Added peer ${result.peer.name} (${result.peer.id}).`);
886
+ console.log(`Node: ${result.peer.nodeId}`);
887
+ console.log(`Fingerprint: ${result.peer.fingerprint}`);
888
+ if (result.tlsFingerprint) console.log(`TLS fingerprint: ${result.tlsFingerprint}`);
889
+ return;
890
+ }
891
+
892
+ if (flags.subcommand === "test") {
893
+ const id = flags.id || await ask(null, "Peer id", "");
894
+ const response = await new clientMod.RemoteRelayClient(store).rpc(id, "peer.ping");
895
+ console.log(`Peer ${id} ok: ${JSON.stringify(response)}`);
896
+ return;
897
+ }
898
+
899
+ if (flags.subcommand === "revoke") {
900
+ const id = flags.id || await ask(null, "Peer id", "");
901
+ console.log(store.revokePeer(id) ? `Revoked peer ${id}.` : `Peer not found: ${id}`);
902
+ return;
903
+ }
904
+
905
+ throw new Error("Usage: nordrelay peer [identity|list|invite|add|test|revoke]");
906
+ }
907
+
656
908
  function parseUserFlags(argv) {
657
909
  const copy = [...argv];
658
910
  const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
@@ -664,6 +916,7 @@ function parseUserFlags(argv) {
664
916
  else if (arg === "--password") flags.password = requireValue(copy, ++i, arg);
665
917
  else if (arg === "--group" || arg === "--groups") flags.groups = requireValue(copy, ++i, arg);
666
918
  else if (arg === "--telegram-user-id") flags.telegramUserId = Number.parseInt(requireValue(copy, ++i, arg), 10);
919
+ else if (arg === "--discord-user-id") flags.discordUserId = requireValue(copy, ++i, arg);
667
920
  else if (arg === "--user-id") flags.userId = requireValue(copy, ++i, arg);
668
921
  }
669
922
  return flags;
@@ -695,8 +948,8 @@ async function commandUser(options) {
695
948
  ? ["admin"]
696
949
  : (flags.groups ? flags.groups.split(",").map((item) => item.trim()).filter(Boolean) : ["user"]);
697
950
  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 });
951
+ ? store.createAdmin({ email, displayName: name, password, telegramUserId: flags.telegramUserId, discordUserId: flags.discordUserId })
952
+ : store.createUser({ email, displayName: name, password, groupIds, telegramUserId: flags.telegramUserId, discordUserId: flags.discordUserId });
700
953
  console.log(`Created user ${created.user.email} (${created.groups.map((group) => group.name).join(", ")}).`);
701
954
  return;
702
955
  }
@@ -721,7 +974,17 @@ async function commandUser(options) {
721
974
  return;
722
975
  }
723
976
 
724
- if (flags.subcommand === "link-code") {
977
+ if (flags.subcommand === "link-discord") {
978
+ const email = flags.email || await ask(null, "Email", "");
979
+ const discordUserId = flags.discordUserId || await ask(null, "Discord user id", "");
980
+ const user = store.getUserByEmail(email);
981
+ if (!user) throw new Error(`User not found: ${email}`);
982
+ store.linkDiscordUser(user.user.id, { discordUserId });
983
+ console.log(`Linked Discord user ${discordUserId} to ${user.user.email}.`);
984
+ return;
985
+ }
986
+
987
+ if (flags.subcommand === "link-code" || flags.subcommand === "telegram-link-code") {
725
988
  const email = flags.email || await ask(null, "Email", "");
726
989
  const user = store.getUserByEmail(email);
727
990
  if (!user) throw new Error(`User not found: ${email}`);
@@ -731,7 +994,17 @@ async function commandUser(options) {
731
994
  return;
732
995
  }
733
996
 
734
- throw new Error("Usage: nordrelay user [list|create-admin|create|reset-password|link-telegram|link-code]");
997
+ if (flags.subcommand === "discord-link-code") {
998
+ const email = flags.email || await ask(null, "Email", "");
999
+ const user = store.getUserByEmail(email);
1000
+ if (!user) throw new Error(`User not found: ${email}`);
1001
+ const code = store.createDiscordLinkCode(user.user.id);
1002
+ console.log(`Discord link code for ${user.user.email}: ${code.code}`);
1003
+ console.log(`Expires: ${code.expiresAt}`);
1004
+ return;
1005
+ }
1006
+
1007
+ 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
1008
  }
736
1009
 
737
1010
  async function commandDoctor(options) {
@@ -741,11 +1014,39 @@ async function commandDoctor(options) {
741
1014
  const userSnapshot = userStore?.snapshot();
742
1015
  const checks = [];
743
1016
  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"));
1017
+ const telegramRequested = process.env.TELEGRAM_ENABLED !== "false";
1018
+ const discordRequested = process.env.DISCORD_ENABLED === "true";
1019
+ const telegramUsable = telegramRequested && Boolean(process.env.TELEGRAM_BOT_TOKEN);
1020
+ const discordUsable = discordRequested && Boolean(process.env.DISCORD_BOT_TOKEN);
1021
+ checks.push(check(
1022
+ "Telegram bot token",
1023
+ !telegramRequested || telegramUsable,
1024
+ telegramRequested ? (telegramUsable ? "configured" : "missing; Telegram adapter will be disabled") : "disabled",
1025
+ telegramRequested && !discordUsable ? "fail" : "warn",
1026
+ ));
1027
+ checks.push(check(
1028
+ "Discord bot token",
1029
+ !discordRequested || discordUsable,
1030
+ discordRequested ? (discordUsable ? "configured" : "missing; Discord adapter will be disabled") : "disabled",
1031
+ discordRequested && !telegramUsable ? "fail" : "warn",
1032
+ ));
1033
+ checks.push(check(
1034
+ "Usable chat adapter",
1035
+ telegramUsable || discordUsable,
1036
+ telegramUsable && discordUsable ? "Telegram and Discord" : telegramUsable ? "Telegram" : discordUsable ? "Discord" : "none",
1037
+ "fail",
1038
+ ));
1039
+ checks.push(check("Discord client ID", !discordUsable || Boolean(process.env.DISCORD_CLIENT_ID), discordUsable ? (process.env.DISCORD_CLIENT_ID ? "configured" : "missing; slash command auto-registration disabled") : "disabled", "warn"));
745
1040
  checks.push(check("User store", Boolean(userStore), userStore ? userStore.filePath : "missing runtime", userStore ? "pass" : "fail"));
746
1041
  checks.push(check("Admin user", Boolean(userSnapshot?.adminConfigured), userSnapshot?.adminConfigured ? "configured" : "missing"));
747
1042
  checks.push(check("WebUI login", true, "required for every dashboard request"));
748
1043
  checks.push(check("Telegram access", true, "requires linked active users and enabled group chats"));
1044
+ checks.push(check("Discord access", true, "requires linked active users and enabled channels"));
1045
+ const peerEnabled = process.env.NORDRELAY_PEER_ENABLED === "true";
1046
+ const peerTlsEnabled = process.env.NORDRELAY_PEER_TLS_ENABLED !== "false";
1047
+ const peerHost = process.env.NORDRELAY_PEER_HOST || "127.0.0.1";
1048
+ checks.push(check("Peer server", peerEnabled, peerEnabled ? `${peerHost}:${process.env.NORDRELAY_PEER_PORT || "31979"}` : "disabled", "warn"));
1049
+ checks.push(check("Peer TLS", !peerEnabled || peerTlsEnabled || isLoopbackName(peerHost), peerTlsEnabled ? "enabled" : "plaintext loopback only", peerEnabled ? "fail" : "warn"));
749
1050
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
750
1051
  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
1052
  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 +1151,22 @@ async function checkOpenClawGateway() {
850
1151
  async function commandWeb(options) {
851
1152
  await mkdirp(options.home);
852
1153
  loadEnvFiles(options.home);
853
- const { host, port } = resolveDashboardEndpoint(options, { strict: true });
1154
+ await prepareRuntimeForLaunch(options);
854
1155
  await ensureConnectorStartedForWeb(options);
1156
+ await startWebDashboard(options, { detached: false });
1157
+ }
1158
+
1159
+ async function startWebDashboard(options, settings = {}) {
1160
+ await mkdirp(options.home);
1161
+ loadEnvFiles(options.home);
1162
+ const { host, port } = resolveDashboardEndpoint(options, { strict: true });
1163
+ const currentPid = await readWebPid(options);
1164
+ if (isProcessRunning(currentPid)) {
1165
+ console.log(`NordRelay dashboard already running with PID ${currentPid}.`);
1166
+ console.log(`NordRelay dashboard: ${formatDashboardUrl({ host, port })}`);
1167
+ return;
1168
+ }
1169
+ await fsp.rm(options.webPidFile, { force: true });
855
1170
  const entry = await resolveWebRuntimeEntry();
856
1171
  if (!entry) {
857
1172
  throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
@@ -864,12 +1179,43 @@ async function commandWeb(options) {
864
1179
  NORDRELAY_DASHBOARD_HOST: host,
865
1180
  NORDRELAY_DASHBOARD_PORT: String(port),
866
1181
  };
1182
+ await writeWebState(options, {
1183
+ status: "starting",
1184
+ pid: null,
1185
+ host,
1186
+ port,
1187
+ url: formatDashboardUrl({ host, port }),
1188
+ });
1189
+ const stdio = settings.detached
1190
+ ? ["ignore", fs.openSync(options.webLogFile, "a"), fs.openSync(options.webLogFile, "a")]
1191
+ : "inherit";
867
1192
  const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
868
1193
  cwd: RUNTIME_ROOT,
869
1194
  env,
870
- stdio: "inherit",
1195
+ detached: Boolean(settings.detached),
1196
+ stdio,
1197
+ });
1198
+ await fsp.writeFile(options.webPidFile, `${child.pid}\n`);
1199
+ await writeWebState(options, {
1200
+ status: "running",
1201
+ pid: child.pid,
1202
+ host,
1203
+ port,
1204
+ url: formatDashboardUrl({ host, port }),
871
1205
  });
872
1206
 
1207
+ if (settings.detached) {
1208
+ child.unref();
1209
+ if (Array.isArray(stdio)) {
1210
+ fs.closeSync(stdio[1]);
1211
+ fs.closeSync(stdio[2]);
1212
+ }
1213
+ console.log(`NordRelay dashboard started with PID ${child.pid}.`);
1214
+ console.log(`NordRelay dashboard: ${formatDashboardUrl({ host, port })}`);
1215
+ console.log(`WebUI log: ${options.webLogFile}`);
1216
+ return;
1217
+ }
1218
+
873
1219
  const forwardSignal = (signal) => {
874
1220
  if (isProcessRunning(child.pid)) {
875
1221
  child.kill(signal);
@@ -878,9 +1224,23 @@ async function commandWeb(options) {
878
1224
  process.once("SIGINT", () => forwardSignal("SIGINT"));
879
1225
  process.once("SIGTERM", () => forwardSignal("SIGTERM"));
880
1226
 
881
- const exit = await new Promise((resolve) => {
1227
+ const exit = await new Promise((resolve, reject) => {
1228
+ child.once("error", reject);
882
1229
  child.once("exit", (code, signal) => resolve({ code, signal }));
883
1230
  });
1231
+ const pid = await readWebPid(options);
1232
+ if (pid === child.pid) {
1233
+ await fsp.rm(options.webPidFile, { force: true });
1234
+ }
1235
+ await writeWebState(options, {
1236
+ status: exit.code === 0 ? "stopped" : "error",
1237
+ pid: null,
1238
+ host,
1239
+ port,
1240
+ url: formatDashboardUrl({ host, port }),
1241
+ exitCode: exit.code,
1242
+ signal: exit.signal,
1243
+ });
884
1244
  if (exit.signal) {
885
1245
  process.kill(process.pid, exit.signal);
886
1246
  return;
@@ -891,6 +1251,7 @@ async function commandWeb(options) {
891
1251
  async function commandForeground(options) {
892
1252
  await mkdirp(options.home);
893
1253
  loadEnvFiles(options.home);
1254
+ await prepareRuntimeForLaunch(options);
894
1255
  process.chdir(RUNTIME_ROOT);
895
1256
 
896
1257
  await writeJsonAtomic(options.stateFile, {
@@ -976,6 +1337,157 @@ async function resolveRuntimeEntry() {
976
1337
  return null;
977
1338
  }
978
1339
 
1340
+ async function prepareRuntimeForLaunch(options) {
1341
+ if (options.buildBeforeStart) {
1342
+ await buildRuntime();
1343
+ options.buildBeforeStart = false;
1344
+ return;
1345
+ }
1346
+ warnIfRuntimeBuildIsStale();
1347
+ }
1348
+
1349
+ function runtimeForwardFlags(flags) {
1350
+ return flags.filter((flag) => flag !== "--build");
1351
+ }
1352
+
1353
+ async function buildRuntime() {
1354
+ if (!isSourceRuntime()) {
1355
+ throw new Error(`Runtime build is only available from a source checkout. Current runtime: ${RUNTIME_ROOT}`);
1356
+ }
1357
+ const npm = resolveNpmSpawnCommand();
1358
+ if (!npm) {
1359
+ throw new Error("npm was not found. Install Node.js/npm or add npm to PATH.");
1360
+ }
1361
+ console.log("Building NordRelay runtime...");
1362
+ await runInteractiveStep("Build runtime", npm.command, [...npm.argsPrefix, "run", "build"], {
1363
+ cwd: RUNTIME_ROOT,
1364
+ shell: npm.shell,
1365
+ });
1366
+ }
1367
+
1368
+ function warnIfRuntimeBuildIsStale() {
1369
+ const status = runtimeBuildStatus();
1370
+ if (!status || !status.stale) {
1371
+ return;
1372
+ }
1373
+ const source = status.sourcePath ? path.relative(RUNTIME_ROOT, status.sourcePath) : "source files";
1374
+ const target = status.targetPath ? path.relative(RUNTIME_ROOT, status.targetPath) : "dist";
1375
+ const reason = status.missing
1376
+ ? `missing ${target}`
1377
+ : `${source} is newer than ${target}`;
1378
+ console.warn(`Warning: NordRelay runtime build may be stale (${reason}). Run \`nordrelay restart --build\` or \`npm run build\`.`);
1379
+ }
1380
+
1381
+ function runtimeBuildStatus() {
1382
+ if (!isSourceRuntime()) {
1383
+ return null;
1384
+ }
1385
+ const source = newestMtime([
1386
+ path.join(RUNTIME_ROOT, "src"),
1387
+ path.join(RUNTIME_ROOT, "plugins", "nordrelay", "scripts"),
1388
+ path.join(RUNTIME_ROOT, "scripts"),
1389
+ path.join(RUNTIME_ROOT, "package.json"),
1390
+ path.join(RUNTIME_ROOT, "tsconfig.json"),
1391
+ path.join(RUNTIME_ROOT, "tsconfig.webui.json"),
1392
+ ]);
1393
+ const distTargets = [
1394
+ path.join(RUNTIME_ROOT, "dist", "index.js"),
1395
+ path.join(RUNTIME_ROOT, "dist", "web-dashboard.js"),
1396
+ path.join(RUNTIME_ROOT, "dist", "webui-assets", "dashboard.js"),
1397
+ path.join(RUNTIME_ROOT, "dist", "webui-assets", "dashboard.css"),
1398
+ ];
1399
+ const missingTarget = distTargets.find((target) => !fs.existsSync(target));
1400
+ if (missingTarget) {
1401
+ return {
1402
+ stale: true,
1403
+ missing: true,
1404
+ sourcePath: source.path,
1405
+ sourceMtimeMs: source.mtimeMs,
1406
+ targetPath: missingTarget,
1407
+ targetMtimeMs: 0,
1408
+ };
1409
+ }
1410
+ const target = oldestMtime(distTargets);
1411
+ return {
1412
+ stale: source.mtimeMs > target.mtimeMs,
1413
+ missing: false,
1414
+ sourcePath: source.path,
1415
+ sourceMtimeMs: source.mtimeMs,
1416
+ targetPath: target.path,
1417
+ targetMtimeMs: target.mtimeMs,
1418
+ };
1419
+ }
1420
+
1421
+ function isSourceRuntime() {
1422
+ return fs.existsSync(path.join(RUNTIME_ROOT, "src", "index.ts")) &&
1423
+ fs.existsSync(path.join(RUNTIME_ROOT, "scripts", "build-web-assets.mjs"));
1424
+ }
1425
+
1426
+ function newestMtime(paths) {
1427
+ let newest = { path: null, mtimeMs: 0 };
1428
+ for (const itemPath of paths) {
1429
+ const candidate = newestMtimeForPath(itemPath);
1430
+ if (candidate.mtimeMs > newest.mtimeMs) {
1431
+ newest = candidate;
1432
+ }
1433
+ }
1434
+ return newest;
1435
+ }
1436
+
1437
+ function newestMtimeForPath(itemPath) {
1438
+ if (!fs.existsSync(itemPath)) {
1439
+ return { path: itemPath, mtimeMs: 0 };
1440
+ }
1441
+ const stat = fs.statSync(itemPath);
1442
+ if (!stat.isDirectory()) {
1443
+ return { path: itemPath, mtimeMs: stat.mtimeMs };
1444
+ }
1445
+ let newest = { path: itemPath, mtimeMs: stat.mtimeMs };
1446
+ for (const entry of fs.readdirSync(itemPath, { withFileTypes: true })) {
1447
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") {
1448
+ continue;
1449
+ }
1450
+ const candidate = newestMtimeForPath(path.join(itemPath, entry.name));
1451
+ if (candidate.mtimeMs > newest.mtimeMs) {
1452
+ newest = candidate;
1453
+ }
1454
+ }
1455
+ return newest;
1456
+ }
1457
+
1458
+ function oldestMtime(paths) {
1459
+ let oldest = { path: null, mtimeMs: Number.POSITIVE_INFINITY };
1460
+ for (const itemPath of paths) {
1461
+ const mtimeMs = fs.statSync(itemPath).mtimeMs;
1462
+ if (mtimeMs < oldest.mtimeMs) {
1463
+ oldest = { path: itemPath, mtimeMs };
1464
+ }
1465
+ }
1466
+ return oldest;
1467
+ }
1468
+
1469
+ async function runInteractiveStep(label, command, args, settings = {}) {
1470
+ console.log(`${label}: ${formatCommand(command, args)}`);
1471
+ const useShell = Boolean(settings.shell);
1472
+ const child = spawn(useShell ? formatShellCommand(command, args) : command, useShell ? [] : args, {
1473
+ cwd: settings.cwd || RUNTIME_ROOT,
1474
+ env: process.env,
1475
+ shell: useShell,
1476
+ stdio: "inherit",
1477
+ windowsHide: false,
1478
+ });
1479
+ const exit = await new Promise((resolve, reject) => {
1480
+ child.once("error", reject);
1481
+ child.once("exit", (code, signal) => resolve({ code, signal }));
1482
+ });
1483
+ if (exit.signal) {
1484
+ throw new Error(`${label} stopped with signal ${exit.signal}`);
1485
+ }
1486
+ if (exit.code !== 0) {
1487
+ throw new Error(`${label} failed with exit code ${exit.code ?? "unknown"}`);
1488
+ }
1489
+ }
1490
+
979
1491
  async function resolveWebRuntimeEntry() {
980
1492
  const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
981
1493
  if (fs.existsSync(distEntry)) {
@@ -1121,6 +1633,10 @@ function isWindowsShellScript(filePath) {
1121
1633
  return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
1122
1634
  }
1123
1635
 
1636
+ function isLoopbackName(host) {
1637
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
1638
+ }
1639
+
1124
1640
  function validateStateBackend() {
1125
1641
  const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
1126
1642
  if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
@@ -1164,19 +1680,61 @@ function sleep(ms) {
1164
1680
  return new Promise((resolve) => setTimeout(resolve, ms));
1165
1681
  }
1166
1682
 
1683
+ function printHelp() {
1684
+ console.log(`${APP_NAME} ${VERSION}`);
1685
+ console.log("");
1686
+ console.log("Usage: nordrelay <command> [options]");
1687
+ console.log("");
1688
+ console.log("Commands:");
1689
+ console.log(" init Create local config and first admin user");
1690
+ console.log(" user Manage users, groups, and channel links");
1691
+ console.log(" peer Manage secure NordRelay peer federation");
1692
+ console.log(" doctor Validate the local setup");
1693
+ console.log(" web, dashboard Start the WebUI and connector");
1694
+ console.log(" start Start the connector");
1695
+ console.log(" stop Stop the connector and WebUI");
1696
+ console.log(" restart Restart the connector");
1697
+ console.log(" status Show connector and WebUI status");
1698
+ console.log(" update Update NordRelay");
1699
+ console.log(" foreground Run the connector in the foreground");
1700
+ console.log(" version Print the installed version");
1701
+ console.log("");
1702
+ console.log("Options:");
1703
+ console.log(" --home <path> Runtime home directory");
1704
+ console.log(" --host <host> WebUI bind host");
1705
+ console.log(" --port <port> WebUI port");
1706
+ console.log(" --build Build source runtime before start/web/restart");
1707
+ console.log(" --force Overwrite existing config during init");
1708
+ console.log(" --help, -h Show this help");
1709
+ console.log(" --version, -v Show the installed version");
1710
+ }
1711
+
1167
1712
  async function main() {
1168
1713
  const options = parseArgs(process.argv.slice(2));
1714
+ if (options.command === "help") {
1715
+ printHelp();
1716
+ return;
1717
+ }
1169
1718
  if (options.command === "start") return commandStart(options);
1170
1719
  if (options.command === "stop") return commandStop(options);
1171
1720
  if (options.command === "status") return commandStatus(options);
1172
1721
  if (options.command === "init") return commandInit(options);
1173
1722
  if (options.command === "user") return commandUser(options);
1723
+ if (options.command === "peer") return commandPeer(options);
1174
1724
  if (options.command === "doctor") return commandDoctor(options);
1175
1725
  if (options.command === "update") return commandUpdate(options);
1176
1726
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
1177
1727
  if (options.command === "restart") {
1728
+ await mkdirp(options.home);
1729
+ loadEnvFiles(options.home);
1730
+ await prepareRuntimeForLaunch(options);
1731
+ const webWasRunning = await isWebDashboardRunning(options);
1178
1732
  await commandStop(options);
1179
- return commandStart(options);
1733
+ await commandStart(options);
1734
+ if (webWasRunning && process.exitCode !== 1) {
1735
+ await startWebDashboard(options, { detached: true });
1736
+ }
1737
+ return;
1180
1738
  }
1181
1739
  if (options.command === "foreground") return commandForeground(options);
1182
1740
  if (options.command === "--version" || options.command === "version") {
@@ -1185,7 +1743,8 @@ async function main() {
1185
1743
  }
1186
1744
 
1187
1745
  console.error(`Unknown command: ${options.command}`);
1188
- console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|update|foreground|version]");
1746
+ console.error("Usage: nordrelay [init|user|peer|doctor|web|start|stop|restart|status|update|foreground|version]");
1747
+ console.error("Run `nordrelay --help` for details.");
1189
1748
  process.exitCode = 2;
1190
1749
  }
1191
1750