@nordbyte/nordrelay 0.6.0 → 0.8.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 (62) hide show
  1. package/.env.example +52 -0
  2. package/README.md +171 -50
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/adapter-conformance.js +61 -0
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot.js +95 -37
  8. package/dist/channel-adapter.js +44 -11
  9. package/dist/channel-command-catalog.js +94 -0
  10. package/dist/channel-command-core.js +60 -0
  11. package/dist/channel-command-service.js +230 -1
  12. package/dist/channel-mirror-registry.js +84 -0
  13. package/dist/channel-peer-prompt.js +95 -0
  14. package/dist/channel-prompt-engine.js +177 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-lifecycle.js +73 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +82 -8
  19. package/dist/config.js +79 -7
  20. package/dist/context-key.js +42 -0
  21. package/dist/discord-bot.js +173 -342
  22. package/dist/discord-command-surface.js +11 -73
  23. package/dist/index.js +29 -0
  24. package/dist/metrics.js +48 -0
  25. package/dist/peer-auth.js +85 -0
  26. package/dist/peer-client.js +288 -0
  27. package/dist/peer-context.js +21 -0
  28. package/dist/peer-identity.js +127 -0
  29. package/dist/peer-readiness.js +77 -0
  30. package/dist/peer-runtime-service.js +658 -0
  31. package/dist/peer-server.js +220 -0
  32. package/dist/peer-store.js +307 -0
  33. package/dist/peer-types.js +52 -0
  34. package/dist/relay-runtime-helpers.js +210 -0
  35. package/dist/relay-runtime.js +79 -274
  36. package/dist/remote-prompt.js +98 -0
  37. package/dist/settings-wizard-test.js +216 -0
  38. package/dist/slack-artifacts.js +165 -0
  39. package/dist/slack-bot.js +1461 -0
  40. package/dist/slack-channel-runtime.js +147 -0
  41. package/dist/slack-command-surface.js +46 -0
  42. package/dist/slack-diagnostics.js +116 -0
  43. package/dist/slack-rate-limit.js +139 -0
  44. package/dist/telegram-command-menu.js +3 -53
  45. package/dist/telegram-general-commands.js +14 -0
  46. package/dist/telegram-preference-commands.js +23 -127
  47. package/dist/user-management-crypto.js +38 -0
  48. package/dist/user-management-normalize.js +188 -0
  49. package/dist/user-management-types.js +1 -0
  50. package/dist/user-management.js +193 -196
  51. package/dist/web-api-contract.js +16 -0
  52. package/dist/web-dashboard-access-routes.js +62 -0
  53. package/dist/web-dashboard-assets.js +1 -0
  54. package/dist/web-dashboard-pages.js +26 -4
  55. package/dist/web-dashboard-peer-routes.js +225 -0
  56. package/dist/web-dashboard-ui.js +1 -0
  57. package/dist/web-dashboard.js +46 -0
  58. package/dist/web-state.js +2 -2
  59. package/dist/webui-assets/dashboard.css +193 -0
  60. package/dist/webui-assets/dashboard.js +870 -57
  61. package/package.json +5 -2
  62. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
@@ -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,6 +68,7 @@ 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);
@@ -70,10 +76,16 @@ function parseArgs(argv) {
70
76
  else if (arg === "--enable-discord") options.enableDiscord = true;
71
77
  else if (arg === "--discord-token") options.discordBotToken = requireValue(copy, ++i, arg);
72
78
  else if (arg === "--discord-client-id") options.discordClientId = requireValue(copy, ++i, arg);
79
+ else if (arg === "--enable-slack") options.enableSlack = true;
80
+ else if (arg === "--slack-bot-token") options.slackBotToken = requireValue(copy, ++i, arg);
81
+ else if (arg === "--slack-app-token") options.slackAppToken = requireValue(copy, ++i, arg);
82
+ else if (arg === "--slack-signing-secret") options.slackSigningSecret = requireValue(copy, ++i, arg);
73
83
  else if (arg === "--admin-email") options.adminEmail = requireValue(copy, ++i, arg);
74
84
  else if (arg === "--admin-name") options.adminName = requireValue(copy, ++i, arg);
75
85
  else if (arg === "--admin-password") options.adminPassword = requireValue(copy, ++i, arg);
76
86
  else if (arg === "--telegram-user-id") options.telegramUserId = requireValue(copy, ++i, arg);
87
+ else if (arg === "--slack-user-id") options.slackUserId = requireValue(copy, ++i, arg);
88
+ else if (arg === "--slack-team-id") options.slackTeamId = requireValue(copy, ++i, arg);
77
89
  else if (arg === "--state-backend") options.stateBackend = requireValue(copy, ++i, arg);
78
90
  else if (arg === "--enable-pi") options.enablePi = true;
79
91
  else if (arg === "--enable-hermes") options.enableHermes = true;
@@ -226,6 +238,7 @@ function formatDashboardUrl(endpoint) {
226
238
  async function commandStart(options, settings = {}) {
227
239
  await mkdirp(options.home);
228
240
  loadEnvFiles(options.home);
241
+ await prepareRuntimeForLaunch(options);
229
242
  const dashboard = resolveDashboardEndpoint(options);
230
243
 
231
244
  const currentPid = await readPid(options.pidFile);
@@ -243,7 +256,7 @@ async function commandStart(options, settings = {}) {
243
256
  });
244
257
 
245
258
  const logFd = fs.openSync(options.logFile, "a");
246
- const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...options.rawFlags], {
259
+ const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...runtimeForwardFlags(options.rawFlags)], {
247
260
  cwd: RUNTIME_ROOT,
248
261
  detached: true,
249
262
  env: process.env,
@@ -410,6 +423,8 @@ async function commandStatus(options) {
410
423
  console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
411
424
  console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
412
425
  console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
426
+ console.log(`Peers: ${state.peerEnabled ? state.peerUrl || "enabled" : "disabled"}`);
427
+ if (state.peerTlsFingerprint) console.log(`Peer TLS fingerprint: ${state.peerTlsFingerprint}`);
413
428
  console.log(`WebUI: ${formatDashboardUrl(dashboard)} (${webStatus})`);
414
429
  console.log(`Log: ${options.logFile}`);
415
430
  console.log(`WebUI log: ${options.webLogFile}`);
@@ -675,10 +690,22 @@ async function commandInit(options) {
675
690
  const discordClientId = enableDiscord === "true"
676
691
  ? options.discordClientId || process.env.DISCORD_CLIENT_ID || await ask(null, "Discord client ID", "")
677
692
  : "";
693
+ const enableSlack = options.enableSlack ? "true" : await askChoice(null, "Enable Slack", "false");
694
+ const slackBotToken = enableSlack === "true"
695
+ ? options.slackBotToken || process.env.SLACK_BOT_TOKEN || await ask(null, "Slack bot token", "")
696
+ : "";
697
+ const slackAppToken = enableSlack === "true"
698
+ ? options.slackAppToken || process.env.SLACK_APP_TOKEN || await ask(null, "Slack app-level token for Socket Mode", "")
699
+ : "";
700
+ const slackSigningSecret = enableSlack === "true"
701
+ ? options.slackSigningSecret || process.env.SLACK_SIGNING_SECRET || await ask(null, "Slack signing secret (optional for Socket Mode)", "")
702
+ : "";
678
703
  const adminEmail = options.adminEmail || await ask(null, "Admin email", "");
679
704
  const adminName = options.adminName || await ask(null, "Admin name", "Admin");
680
705
  const adminPassword = options.adminPassword || await askSecret(null, "Admin password", "");
681
706
  const telegramUserId = options.telegramUserId || await ask(null, "Optional Telegram user id to link", "");
707
+ const slackUserId = options.slackUserId || await ask(null, "Optional Slack user id to link", "");
708
+ const slackTeamId = slackUserId ? (options.slackTeamId || await ask(null, "Optional Slack team id for linked user", "")) : "";
682
709
  const enableCodex = options.disableCodex ? "false" : await askChoice(null, "Enable Codex", "true");
683
710
  const enablePi = options.enablePi ? "true" : await askChoice(null, "Enable Pi", "false");
684
711
  const enableHermes = options.enableHermes ? "true" : await askChoice(null, "Enable Hermes", "false");
@@ -688,7 +715,9 @@ async function commandInit(options) {
688
715
 
689
716
  if (enableTelegram === "true" && !telegramBotToken) throw new Error("Telegram bot token is required when Telegram is enabled.");
690
717
  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.");
718
+ if (enableSlack === "true" && !slackBotToken) throw new Error("Slack bot token is required when Slack is enabled.");
719
+ if (enableSlack === "true" && !slackAppToken) throw new Error("Slack app-level token is required for default Socket Mode.");
720
+ if (enableTelegram !== "true" && enableDiscord !== "true" && enableSlack !== "true") throw new Error("At least one chat adapter must be enabled.");
692
721
  if (!adminEmail) throw new Error("Admin email is required.");
693
722
  if (!adminPassword) throw new Error("Admin password is required.");
694
723
  if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
@@ -713,6 +742,13 @@ async function commandInit(options) {
713
742
  "DISCORD_COMMAND_MODE=both",
714
743
  "DISCORD_MESSAGE_CONTENT_ENABLED=true",
715
744
  "DISCORD_AUTO_REGISTER_COMMANDS=true",
745
+ `SLACK_ENABLED=${enableSlack}`,
746
+ `SLACK_BOT_TOKEN=${slackBotToken}`,
747
+ `SLACK_APP_TOKEN=${slackAppToken}`,
748
+ `SLACK_SIGNING_SECRET=${slackSigningSecret}`,
749
+ "SLACK_SOCKET_MODE=true",
750
+ "SLACK_MESSAGE_CONTENT_ENABLED=true",
751
+ "SLACK_AUTO_SEND_ARTIFACTS=false",
716
752
  `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
717
753
  `NORDRELAY_PI_ENABLED=${enablePi}`,
718
754
  `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
@@ -727,6 +763,11 @@ async function commandInit(options) {
727
763
  "OPENCLAW_DEFAULT_PROFILE=default",
728
764
  "CLAUDE_CODE_DEFAULT_PROFILE=default",
729
765
  "CLAUDE_CODE_MAX_TURNS=100",
766
+ "NORDRELAY_PEER_ENABLED=false",
767
+ "NORDRELAY_PEER_HOST=127.0.0.1",
768
+ "NORDRELAY_PEER_PORT=31979",
769
+ "NORDRELAY_PEER_TLS_ENABLED=true",
770
+ "NORDRELAY_PEER_REQUIRE_TLS=true",
730
771
  `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
731
772
  "TELEGRAM_TRANSPORT=polling",
732
773
  "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
@@ -740,6 +781,8 @@ async function commandInit(options) {
740
781
  displayName: adminName || adminEmail,
741
782
  password: adminPassword,
742
783
  telegramUserId: telegramUserId ? Number(telegramUserId) : undefined,
784
+ slackUserId: slackUserId || undefined,
785
+ slackTeamId: slackTeamId || undefined,
743
786
  });
744
787
  console.log(`Wrote ${envPath}`);
745
788
  console.log(`Created admin user ${adminEmail}.`);
@@ -755,6 +798,165 @@ async function createUserStore(home) {
755
798
  return new mod.UserStore(home);
756
799
  }
757
800
 
801
+ async function peerModules() {
802
+ const required = [
803
+ "peer-store.js",
804
+ "peer-identity.js",
805
+ "peer-client.js",
806
+ ];
807
+ for (const file of required) {
808
+ const modulePath = path.join(RUNTIME_ROOT, "dist", file);
809
+ if (!fs.existsSync(modulePath)) {
810
+ throw new Error(`Missing peer runtime. Run \`npm run build\` in ${RUNTIME_ROOT}.`);
811
+ }
812
+ }
813
+ const [store, identity, client] = await Promise.all(required.map((file) => import(pathToFileURL(path.join(RUNTIME_ROOT, "dist", file)).href)));
814
+ return { store, identity, client };
815
+ }
816
+
817
+ function parsePeerFlags(argv) {
818
+ const copy = [...argv];
819
+ const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
820
+ const flags = { subcommand, url: undefined };
821
+ if (["add", "test", "check", "revoke"].includes(subcommand) && copy[0] && !copy[0].startsWith("-")) {
822
+ flags.url = copy.shift();
823
+ flags.id = flags.url;
824
+ }
825
+ for (let i = 0; i < copy.length; i += 1) {
826
+ const arg = copy[i];
827
+ if (arg === "--name") flags.name = requireValue(copy, ++i, arg);
828
+ else if (arg === "--code") flags.code = requireValue(copy, ++i, arg);
829
+ else if (arg === "--expect-fingerprint") flags.expectFingerprint = requireValue(copy, ++i, arg);
830
+ else if (arg === "--public-url") flags.publicUrl = requireValue(copy, ++i, arg);
831
+ else if (arg === "--expires" || arg === "--expires-minutes") flags.expiresMinutes = Number.parseInt(requireValue(copy, ++i, arg), 10);
832
+ else if (arg === "--scopes") flags.scopes = requireValue(copy, ++i, arg);
833
+ else if (arg === "--agents") flags.agents = requireValue(copy, ++i, arg);
834
+ else if (arg === "--workspaces") flags.workspaces = requireValue(copy, ++i, arg);
835
+ else if (arg === "--workspace-aliases" || arg === "--aliases") flags.workspaceAliases = requireValue(copy, ++i, arg);
836
+ }
837
+ return flags;
838
+ }
839
+
840
+ function csv(value) {
841
+ return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : undefined;
842
+ }
843
+
844
+ function aliasMap(value) {
845
+ if (!value) return undefined;
846
+ return Object.fromEntries(value.split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([alias, workspace]) => alias && workspace));
847
+ }
848
+
849
+ async function commandPeer(options) {
850
+ await mkdirp(options.home);
851
+ loadEnvFiles(options.home);
852
+ const flags = parsePeerFlags(options.rawFlags);
853
+ const { store: storeMod, identity: identityMod, client: clientMod } = await peerModules();
854
+ const store = new storeMod.PeerStore(options.home);
855
+ const identity = identityMod.loadOrCreatePeerIdentity(options.home, process.env.NORDRELAY_PEER_NAME);
856
+
857
+ if (flags.subcommand === "identity") {
858
+ console.log(`Node ID: ${identity.public.nodeId}`);
859
+ console.log(`Name: ${identity.public.name}`);
860
+ console.log(`Fingerprint: ${identity.public.fingerprint}`);
861
+ console.log(`Created: ${identity.public.createdAt}`);
862
+ return;
863
+ }
864
+
865
+ if (flags.subcommand === "list") {
866
+ const peers = store.listPublic();
867
+ if (peers.length === 0) {
868
+ console.log("No peers configured.");
869
+ console.log("Create an invite with `nordrelay peer invite` or add a peer with `nordrelay peer add <url> --code <code>`.");
870
+ return;
871
+ }
872
+ for (const peer of peers) {
873
+ console.log(`${peer.id} ${peer.enabled ? "enabled" : "disabled"} ${peer.name}`);
874
+ console.log(` URL: ${peer.url || "-"}`);
875
+ console.log(` Node: ${peer.nodeId} ${peer.fingerprint}`);
876
+ console.log(` Direction: ${peer.direction}`);
877
+ console.log(` Scopes: ${peer.scopes.join(",") || "-"}`);
878
+ console.log(` Agents: ${peer.allowedAgents.join(",") || "all"}`);
879
+ const aliases = Object.entries(peer.workspaceAliases || {}).map(([alias, workspace]) => `${alias}=${workspace}`).join(",");
880
+ if (aliases) console.log(` Workspace aliases: ${aliases}`);
881
+ if (peer.lastSeenAt) console.log(` Last seen: ${peer.lastSeenAt}`);
882
+ if (peer.lastLatencyMs !== undefined) console.log(` Latency: ${peer.lastLatencyMs}ms`);
883
+ if (peer.remoteVersion) console.log(` Remote version: ${peer.remoteVersion}`);
884
+ if (peer.lastError) console.log(` Last error: ${peer.lastError}`);
885
+ }
886
+ return;
887
+ }
888
+
889
+ if (flags.subcommand === "invite") {
890
+ 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"}`;
891
+ const peerEnabled = process.env.NORDRELAY_PEER_ENABLED === "true";
892
+ if (!peerEnabled) {
893
+ console.log("Warning: peer server is disabled. The invite can be created, but pairing will fail until NORDRELAY_PEER_ENABLED=true and NordRelay is restarted.");
894
+ } else {
895
+ const probe = await clientMod.checkPeerEndpoint(url, { timeoutMs: 2500 });
896
+ if (!probe.ok) {
897
+ console.log(`Warning: peer endpoint is not reachable from this machine: ${probe.detail}`);
898
+ }
899
+ }
900
+ const created = store.createInvitation({
901
+ name: flags.name,
902
+ expiresInMs: Number.isFinite(flags.expiresMinutes) ? flags.expiresMinutes * 60 * 1000 : undefined,
903
+ scopes: csv(flags.scopes),
904
+ allowedAgents: csv(flags.agents),
905
+ allowedWorkspaceRoots: csv(flags.workspaces),
906
+ workspaceAliases: aliasMap(flags.workspaceAliases),
907
+ });
908
+ console.log(`Pairing code: ${created.code}`);
909
+ console.log(`Expires: ${created.invitation.expiresAt}`);
910
+ console.log(`Fingerprint: ${identity.public.fingerprint}`);
911
+ console.log(`Command: nordrelay peer add ${url} --code ${created.code}`);
912
+ return;
913
+ }
914
+
915
+ if (flags.subcommand === "add") {
916
+ const url = flags.url || await ask(null, "Peer URL", "");
917
+ const code = flags.code || await ask(null, "Pairing code", "");
918
+ const result = await clientMod.pairPeer({
919
+ url,
920
+ code,
921
+ name: flags.name,
922
+ publicUrl: flags.publicUrl,
923
+ }, identity, store);
924
+ console.log(`Added peer ${result.peer.name} (${result.peer.id}).`);
925
+ console.log(`Node: ${result.peer.nodeId}`);
926
+ console.log(`Fingerprint: ${result.peer.fingerprint}`);
927
+ if (result.tlsFingerprint) console.log(`TLS fingerprint: ${result.tlsFingerprint}`);
928
+ return;
929
+ }
930
+
931
+ if (flags.subcommand === "test") {
932
+ const id = flags.id || await ask(null, "Peer id", "");
933
+ const response = await new clientMod.RemoteRelayClient(store).rpc(id, "peer.ping");
934
+ console.log(`Peer ${id} ok: ${JSON.stringify(response)}`);
935
+ return;
936
+ }
937
+
938
+ if (flags.subcommand === "check") {
939
+ const url = flags.url || await ask(null, "Peer URL", "");
940
+ const probe = await clientMod.checkPeerEndpoint(url, { expectedTlsFingerprint: flags.expectFingerprint });
941
+ console.log(`Peer endpoint: ${probe.url}`);
942
+ console.log(`Status: ${probe.ok ? "reachable" : "unreachable"}`);
943
+ if (probe.latencyMs !== undefined) console.log(`Latency: ${probe.latencyMs}ms`);
944
+ if (probe.statusCode !== undefined) console.log(`HTTP status: ${probe.statusCode}`);
945
+ if (probe.tlsFingerprint) console.log(`TLS fingerprint: ${probe.tlsFingerprint}`);
946
+ console.log(`Detail: ${probe.detail}`);
947
+ if (!probe.ok) process.exitCode = 1;
948
+ return;
949
+ }
950
+
951
+ if (flags.subcommand === "revoke") {
952
+ const id = flags.id || await ask(null, "Peer id", "");
953
+ console.log(store.revokePeer(id) ? `Revoked peer ${id}.` : `Peer not found: ${id}`);
954
+ return;
955
+ }
956
+
957
+ throw new Error("Usage: nordrelay peer [identity|list|invite|add|test|check|revoke]");
958
+ }
959
+
758
960
  function parseUserFlags(argv) {
759
961
  const copy = [...argv];
760
962
  const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
@@ -767,6 +969,8 @@ function parseUserFlags(argv) {
767
969
  else if (arg === "--group" || arg === "--groups") flags.groups = requireValue(copy, ++i, arg);
768
970
  else if (arg === "--telegram-user-id") flags.telegramUserId = Number.parseInt(requireValue(copy, ++i, arg), 10);
769
971
  else if (arg === "--discord-user-id") flags.discordUserId = requireValue(copy, ++i, arg);
972
+ else if (arg === "--slack-user-id") flags.slackUserId = requireValue(copy, ++i, arg);
973
+ else if (arg === "--slack-team-id") flags.slackTeamId = requireValue(copy, ++i, arg);
770
974
  else if (arg === "--user-id") flags.userId = requireValue(copy, ++i, arg);
771
975
  }
772
976
  return flags;
@@ -798,8 +1002,8 @@ async function commandUser(options) {
798
1002
  ? ["admin"]
799
1003
  : (flags.groups ? flags.groups.split(",").map((item) => item.trim()).filter(Boolean) : ["user"]);
800
1004
  const created = flags.subcommand === "create-admin"
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 });
1005
+ ? store.createAdmin({ email, displayName: name, password, telegramUserId: flags.telegramUserId, discordUserId: flags.discordUserId, slackUserId: flags.slackUserId, slackTeamId: flags.slackTeamId })
1006
+ : store.createUser({ email, displayName: name, password, groupIds, telegramUserId: flags.telegramUserId, discordUserId: flags.discordUserId, slackUserId: flags.slackUserId, slackTeamId: flags.slackTeamId });
803
1007
  console.log(`Created user ${created.user.email} (${created.groups.map((group) => group.name).join(", ")}).`);
804
1008
  return;
805
1009
  }
@@ -834,6 +1038,17 @@ async function commandUser(options) {
834
1038
  return;
835
1039
  }
836
1040
 
1041
+ if (flags.subcommand === "link-slack") {
1042
+ const email = flags.email || await ask(null, "Email", "");
1043
+ const slackUserId = flags.slackUserId || await ask(null, "Slack user id", "");
1044
+ const teamId = flags.slackTeamId || await ask(null, "Slack team id (optional)", "");
1045
+ const user = store.getUserByEmail(email);
1046
+ if (!user) throw new Error(`User not found: ${email}`);
1047
+ store.linkSlackUser(user.user.id, { slackUserId, teamId: teamId || undefined });
1048
+ console.log(`Linked Slack user ${slackUserId} to ${user.user.email}.`);
1049
+ return;
1050
+ }
1051
+
837
1052
  if (flags.subcommand === "link-code" || flags.subcommand === "telegram-link-code") {
838
1053
  const email = flags.email || await ask(null, "Email", "");
839
1054
  const user = store.getUserByEmail(email);
@@ -854,7 +1069,17 @@ async function commandUser(options) {
854
1069
  return;
855
1070
  }
856
1071
 
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]");
1072
+ if (flags.subcommand === "slack-link-code") {
1073
+ const email = flags.email || await ask(null, "Email", "");
1074
+ const user = store.getUserByEmail(email);
1075
+ if (!user) throw new Error(`User not found: ${email}`);
1076
+ const code = store.createSlackLinkCode(user.user.id);
1077
+ console.log(`Slack link code for ${user.user.email}: ${code.code}`);
1078
+ console.log(`Expires: ${code.expiresAt}`);
1079
+ return;
1080
+ }
1081
+
1082
+ throw new Error("Usage: nordrelay user [list|create-admin|create|reset-password|link-telegram|link-discord|link-slack|link-code|telegram-link-code|discord-link-code|slack-link-code]");
858
1083
  }
859
1084
 
860
1085
  async function commandDoctor(options) {
@@ -864,16 +1089,55 @@ async function commandDoctor(options) {
864
1089
  const userSnapshot = userStore?.snapshot();
865
1090
  const checks = [];
866
1091
  checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
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"));
1092
+ const telegramRequested = process.env.TELEGRAM_ENABLED !== "false";
1093
+ const discordRequested = process.env.DISCORD_ENABLED === "true";
1094
+ const slackRequested = process.env.SLACK_ENABLED === "true";
1095
+ const slackSocketMode = process.env.SLACK_SOCKET_MODE !== "false";
1096
+ const telegramUsable = telegramRequested && Boolean(process.env.TELEGRAM_BOT_TOKEN);
1097
+ const discordUsable = discordRequested && Boolean(process.env.DISCORD_BOT_TOKEN);
1098
+ const slackUsable = slackRequested && Boolean(process.env.SLACK_BOT_TOKEN) && (slackSocketMode ? Boolean(process.env.SLACK_APP_TOKEN) : Boolean(process.env.SLACK_SIGNING_SECRET));
1099
+ checks.push(check(
1100
+ "Telegram bot token",
1101
+ !telegramRequested || telegramUsable,
1102
+ telegramRequested ? (telegramUsable ? "configured" : "missing; Telegram adapter will be disabled") : "disabled",
1103
+ telegramRequested && !discordUsable && !slackUsable ? "fail" : "warn",
1104
+ ));
1105
+ checks.push(check(
1106
+ "Discord bot token",
1107
+ !discordRequested || discordUsable,
1108
+ discordRequested ? (discordUsable ? "configured" : "missing; Discord adapter will be disabled") : "disabled",
1109
+ discordRequested && !telegramUsable && !slackUsable ? "fail" : "warn",
1110
+ ));
1111
+ checks.push(check(
1112
+ "Slack bot token",
1113
+ !slackRequested || Boolean(process.env.SLACK_BOT_TOKEN),
1114
+ slackRequested ? (process.env.SLACK_BOT_TOKEN ? "configured" : "missing; Slack adapter will be disabled") : "disabled",
1115
+ slackRequested && !telegramUsable && !discordUsable ? "fail" : "warn",
1116
+ ));
1117
+ checks.push(check(
1118
+ slackSocketMode ? "Slack app token" : "Slack signing secret",
1119
+ !slackRequested || slackUsable,
1120
+ slackRequested ? (slackUsable ? "configured" : `missing; ${slackSocketMode ? "Socket Mode requires SLACK_APP_TOKEN" : "HTTP mode requires SLACK_SIGNING_SECRET"}`) : "disabled",
1121
+ slackRequested && !telegramUsable && !discordUsable ? "fail" : "warn",
1122
+ ));
1123
+ checks.push(check(
1124
+ "Usable chat adapter",
1125
+ telegramUsable || discordUsable || slackUsable,
1126
+ [telegramUsable ? "Telegram" : "", discordUsable ? "Discord" : "", slackUsable ? "Slack" : ""].filter(Boolean).join(" and ") || "none",
1127
+ "fail",
1128
+ ));
1129
+ 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"));
872
1130
  checks.push(check("User store", Boolean(userStore), userStore ? userStore.filePath : "missing runtime", userStore ? "pass" : "fail"));
873
1131
  checks.push(check("Admin user", Boolean(userSnapshot?.adminConfigured), userSnapshot?.adminConfigured ? "configured" : "missing"));
874
1132
  checks.push(check("WebUI login", true, "required for every dashboard request"));
875
1133
  checks.push(check("Telegram access", true, "requires linked active users and enabled group chats"));
876
1134
  checks.push(check("Discord access", true, "requires linked active users and enabled channels"));
1135
+ checks.push(check("Slack access", true, "requires linked active users and enabled channels"));
1136
+ const peerEnabled = process.env.NORDRELAY_PEER_ENABLED === "true";
1137
+ const peerTlsEnabled = process.env.NORDRELAY_PEER_TLS_ENABLED !== "false";
1138
+ const peerHost = process.env.NORDRELAY_PEER_HOST || "127.0.0.1";
1139
+ checks.push(check("Peer server", peerEnabled, peerEnabled ? `${peerHost}:${process.env.NORDRELAY_PEER_PORT || "31979"}` : "disabled", "warn"));
1140
+ checks.push(check("Peer TLS", !peerEnabled || peerTlsEnabled || isLoopbackName(peerHost), peerTlsEnabled ? "enabled" : "plaintext loopback only", peerEnabled ? "fail" : "warn"));
877
1141
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
878
1142
  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"));
879
1143
  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"));
@@ -978,6 +1242,7 @@ async function checkOpenClawGateway() {
978
1242
  async function commandWeb(options) {
979
1243
  await mkdirp(options.home);
980
1244
  loadEnvFiles(options.home);
1245
+ await prepareRuntimeForLaunch(options);
981
1246
  await ensureConnectorStartedForWeb(options);
982
1247
  await startWebDashboard(options, { detached: false });
983
1248
  }
@@ -1077,6 +1342,7 @@ async function startWebDashboard(options, settings = {}) {
1077
1342
  async function commandForeground(options) {
1078
1343
  await mkdirp(options.home);
1079
1344
  loadEnvFiles(options.home);
1345
+ await prepareRuntimeForLaunch(options);
1080
1346
  process.chdir(RUNTIME_ROOT);
1081
1347
 
1082
1348
  await writeJsonAtomic(options.stateFile, {
@@ -1162,6 +1428,157 @@ async function resolveRuntimeEntry() {
1162
1428
  return null;
1163
1429
  }
1164
1430
 
1431
+ async function prepareRuntimeForLaunch(options) {
1432
+ if (options.buildBeforeStart) {
1433
+ await buildRuntime();
1434
+ options.buildBeforeStart = false;
1435
+ return;
1436
+ }
1437
+ warnIfRuntimeBuildIsStale();
1438
+ }
1439
+
1440
+ function runtimeForwardFlags(flags) {
1441
+ return flags.filter((flag) => flag !== "--build");
1442
+ }
1443
+
1444
+ async function buildRuntime() {
1445
+ if (!isSourceRuntime()) {
1446
+ throw new Error(`Runtime build is only available from a source checkout. Current runtime: ${RUNTIME_ROOT}`);
1447
+ }
1448
+ const npm = resolveNpmSpawnCommand();
1449
+ if (!npm) {
1450
+ throw new Error("npm was not found. Install Node.js/npm or add npm to PATH.");
1451
+ }
1452
+ console.log("Building NordRelay runtime...");
1453
+ await runInteractiveStep("Build runtime", npm.command, [...npm.argsPrefix, "run", "build"], {
1454
+ cwd: RUNTIME_ROOT,
1455
+ shell: npm.shell,
1456
+ });
1457
+ }
1458
+
1459
+ function warnIfRuntimeBuildIsStale() {
1460
+ const status = runtimeBuildStatus();
1461
+ if (!status || !status.stale) {
1462
+ return;
1463
+ }
1464
+ const source = status.sourcePath ? path.relative(RUNTIME_ROOT, status.sourcePath) : "source files";
1465
+ const target = status.targetPath ? path.relative(RUNTIME_ROOT, status.targetPath) : "dist";
1466
+ const reason = status.missing
1467
+ ? `missing ${target}`
1468
+ : `${source} is newer than ${target}`;
1469
+ console.warn(`Warning: NordRelay runtime build may be stale (${reason}). Run \`nordrelay restart --build\` or \`npm run build\`.`);
1470
+ }
1471
+
1472
+ function runtimeBuildStatus() {
1473
+ if (!isSourceRuntime()) {
1474
+ return null;
1475
+ }
1476
+ const source = newestMtime([
1477
+ path.join(RUNTIME_ROOT, "src"),
1478
+ path.join(RUNTIME_ROOT, "plugins", "nordrelay", "scripts"),
1479
+ path.join(RUNTIME_ROOT, "scripts"),
1480
+ path.join(RUNTIME_ROOT, "package.json"),
1481
+ path.join(RUNTIME_ROOT, "tsconfig.json"),
1482
+ path.join(RUNTIME_ROOT, "tsconfig.webui.json"),
1483
+ ]);
1484
+ const distTargets = [
1485
+ path.join(RUNTIME_ROOT, "dist", "index.js"),
1486
+ path.join(RUNTIME_ROOT, "dist", "web-dashboard.js"),
1487
+ path.join(RUNTIME_ROOT, "dist", "webui-assets", "dashboard.js"),
1488
+ path.join(RUNTIME_ROOT, "dist", "webui-assets", "dashboard.css"),
1489
+ ];
1490
+ const missingTarget = distTargets.find((target) => !fs.existsSync(target));
1491
+ if (missingTarget) {
1492
+ return {
1493
+ stale: true,
1494
+ missing: true,
1495
+ sourcePath: source.path,
1496
+ sourceMtimeMs: source.mtimeMs,
1497
+ targetPath: missingTarget,
1498
+ targetMtimeMs: 0,
1499
+ };
1500
+ }
1501
+ const target = oldestMtime(distTargets);
1502
+ return {
1503
+ stale: source.mtimeMs > target.mtimeMs,
1504
+ missing: false,
1505
+ sourcePath: source.path,
1506
+ sourceMtimeMs: source.mtimeMs,
1507
+ targetPath: target.path,
1508
+ targetMtimeMs: target.mtimeMs,
1509
+ };
1510
+ }
1511
+
1512
+ function isSourceRuntime() {
1513
+ return fs.existsSync(path.join(RUNTIME_ROOT, "src", "index.ts")) &&
1514
+ fs.existsSync(path.join(RUNTIME_ROOT, "scripts", "build-web-assets.mjs"));
1515
+ }
1516
+
1517
+ function newestMtime(paths) {
1518
+ let newest = { path: null, mtimeMs: 0 };
1519
+ for (const itemPath of paths) {
1520
+ const candidate = newestMtimeForPath(itemPath);
1521
+ if (candidate.mtimeMs > newest.mtimeMs) {
1522
+ newest = candidate;
1523
+ }
1524
+ }
1525
+ return newest;
1526
+ }
1527
+
1528
+ function newestMtimeForPath(itemPath) {
1529
+ if (!fs.existsSync(itemPath)) {
1530
+ return { path: itemPath, mtimeMs: 0 };
1531
+ }
1532
+ const stat = fs.statSync(itemPath);
1533
+ if (!stat.isDirectory()) {
1534
+ return { path: itemPath, mtimeMs: stat.mtimeMs };
1535
+ }
1536
+ let newest = { path: itemPath, mtimeMs: stat.mtimeMs };
1537
+ for (const entry of fs.readdirSync(itemPath, { withFileTypes: true })) {
1538
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") {
1539
+ continue;
1540
+ }
1541
+ const candidate = newestMtimeForPath(path.join(itemPath, entry.name));
1542
+ if (candidate.mtimeMs > newest.mtimeMs) {
1543
+ newest = candidate;
1544
+ }
1545
+ }
1546
+ return newest;
1547
+ }
1548
+
1549
+ function oldestMtime(paths) {
1550
+ let oldest = { path: null, mtimeMs: Number.POSITIVE_INFINITY };
1551
+ for (const itemPath of paths) {
1552
+ const mtimeMs = fs.statSync(itemPath).mtimeMs;
1553
+ if (mtimeMs < oldest.mtimeMs) {
1554
+ oldest = { path: itemPath, mtimeMs };
1555
+ }
1556
+ }
1557
+ return oldest;
1558
+ }
1559
+
1560
+ async function runInteractiveStep(label, command, args, settings = {}) {
1561
+ console.log(`${label}: ${formatCommand(command, args)}`);
1562
+ const useShell = Boolean(settings.shell);
1563
+ const child = spawn(useShell ? formatShellCommand(command, args) : command, useShell ? [] : args, {
1564
+ cwd: settings.cwd || RUNTIME_ROOT,
1565
+ env: process.env,
1566
+ shell: useShell,
1567
+ stdio: "inherit",
1568
+ windowsHide: false,
1569
+ });
1570
+ const exit = await new Promise((resolve, reject) => {
1571
+ child.once("error", reject);
1572
+ child.once("exit", (code, signal) => resolve({ code, signal }));
1573
+ });
1574
+ if (exit.signal) {
1575
+ throw new Error(`${label} stopped with signal ${exit.signal}`);
1576
+ }
1577
+ if (exit.code !== 0) {
1578
+ throw new Error(`${label} failed with exit code ${exit.code ?? "unknown"}`);
1579
+ }
1580
+ }
1581
+
1165
1582
  async function resolveWebRuntimeEntry() {
1166
1583
  const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
1167
1584
  if (fs.existsSync(distEntry)) {
@@ -1307,6 +1724,10 @@ function isWindowsShellScript(filePath) {
1307
1724
  return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
1308
1725
  }
1309
1726
 
1727
+ function isLoopbackName(host) {
1728
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
1729
+ }
1730
+
1310
1731
  function validateStateBackend() {
1311
1732
  const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
1312
1733
  if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
@@ -1350,19 +1771,54 @@ function sleep(ms) {
1350
1771
  return new Promise((resolve) => setTimeout(resolve, ms));
1351
1772
  }
1352
1773
 
1774
+ function printHelp() {
1775
+ console.log(`${APP_NAME} ${VERSION}`);
1776
+ console.log("");
1777
+ console.log("Usage: nordrelay <command> [options]");
1778
+ console.log("");
1779
+ console.log("Commands:");
1780
+ console.log(" init Create local config and first admin user");
1781
+ console.log(" user Manage users, groups, and channel links");
1782
+ console.log(" peer Manage secure NordRelay peer federation");
1783
+ console.log(" doctor Validate the local setup");
1784
+ console.log(" web, dashboard Start the WebUI and connector");
1785
+ console.log(" start Start the connector");
1786
+ console.log(" stop Stop the connector and WebUI");
1787
+ console.log(" restart Restart the connector");
1788
+ console.log(" status Show connector and WebUI status");
1789
+ console.log(" update Update NordRelay");
1790
+ console.log(" foreground Run the connector in the foreground");
1791
+ console.log(" version Print the installed version");
1792
+ console.log("");
1793
+ console.log("Options:");
1794
+ console.log(" --home <path> Runtime home directory");
1795
+ console.log(" --host <host> WebUI bind host");
1796
+ console.log(" --port <port> WebUI port");
1797
+ console.log(" --build Build source runtime before start/web/restart");
1798
+ console.log(" --force Overwrite existing config during init");
1799
+ console.log(" --help, -h Show this help");
1800
+ console.log(" --version, -v Show the installed version");
1801
+ }
1802
+
1353
1803
  async function main() {
1354
1804
  const options = parseArgs(process.argv.slice(2));
1805
+ if (options.command === "help") {
1806
+ printHelp();
1807
+ return;
1808
+ }
1355
1809
  if (options.command === "start") return commandStart(options);
1356
1810
  if (options.command === "stop") return commandStop(options);
1357
1811
  if (options.command === "status") return commandStatus(options);
1358
1812
  if (options.command === "init") return commandInit(options);
1359
1813
  if (options.command === "user") return commandUser(options);
1814
+ if (options.command === "peer") return commandPeer(options);
1360
1815
  if (options.command === "doctor") return commandDoctor(options);
1361
1816
  if (options.command === "update") return commandUpdate(options);
1362
1817
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
1363
1818
  if (options.command === "restart") {
1364
1819
  await mkdirp(options.home);
1365
1820
  loadEnvFiles(options.home);
1821
+ await prepareRuntimeForLaunch(options);
1366
1822
  const webWasRunning = await isWebDashboardRunning(options);
1367
1823
  await commandStop(options);
1368
1824
  await commandStart(options);
@@ -1378,7 +1834,8 @@ async function main() {
1378
1834
  }
1379
1835
 
1380
1836
  console.error(`Unknown command: ${options.command}`);
1381
- console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|update|foreground|version]");
1837
+ console.error("Usage: nordrelay [init|user|peer|doctor|web|start|stop|restart|status|update|foreground|version]");
1838
+ console.error("Run `nordrelay --help` for details.");
1382
1839
  process.exitCode = 2;
1383
1840
  }
1384
1841