@nordbyte/nordrelay 0.6.0 → 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 (42) hide show
  1. package/.env.example +17 -0
  2. package/README.md +67 -6
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/bot-preferences.js +1 -0
  6. package/dist/bot.js +77 -6
  7. package/dist/channel-adapter.js +11 -5
  8. package/dist/channel-command-catalog.js +88 -0
  9. package/dist/channel-command-service.js +214 -1
  10. package/dist/channel-mirror-registry.js +77 -0
  11. package/dist/channel-peer-prompt.js +95 -0
  12. package/dist/channel-runtime.js +12 -5
  13. package/dist/codex-state.js +114 -78
  14. package/dist/config-metadata.js +15 -0
  15. package/dist/config.js +31 -6
  16. package/dist/context-key.js +10 -0
  17. package/dist/discord-bot.js +85 -26
  18. package/dist/discord-command-surface.js +11 -73
  19. package/dist/index.js +20 -0
  20. package/dist/metrics.js +46 -0
  21. package/dist/peer-auth.js +85 -0
  22. package/dist/peer-client.js +256 -0
  23. package/dist/peer-context.js +21 -0
  24. package/dist/peer-identity.js +127 -0
  25. package/dist/peer-runtime-service.js +636 -0
  26. package/dist/peer-server.js +220 -0
  27. package/dist/peer-store.js +294 -0
  28. package/dist/peer-types.js +52 -0
  29. package/dist/relay-runtime-helpers.js +208 -0
  30. package/dist/relay-runtime.js +72 -274
  31. package/dist/remote-prompt.js +98 -0
  32. package/dist/telegram-command-menu.js +3 -53
  33. package/dist/telegram-general-commands.js +14 -0
  34. package/dist/telegram-preference-commands.js +23 -127
  35. package/dist/web-api-contract.js +8 -0
  36. package/dist/web-dashboard-pages.js +12 -0
  37. package/dist/web-dashboard-peer-routes.js +204 -0
  38. package/dist/web-dashboard-ui.js +1 -0
  39. package/dist/web-dashboard.js +12 -0
  40. package/dist/webui-assets/dashboard.js +427 -14
  41. package/package.json +3 -2
  42. package/plugins/nordrelay/scripts/nordrelay.mjs +373 -7
@@ -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);
@@ -226,6 +232,7 @@ function formatDashboardUrl(endpoint) {
226
232
  async function commandStart(options, settings = {}) {
227
233
  await mkdirp(options.home);
228
234
  loadEnvFiles(options.home);
235
+ await prepareRuntimeForLaunch(options);
229
236
  const dashboard = resolveDashboardEndpoint(options);
230
237
 
231
238
  const currentPid = await readPid(options.pidFile);
@@ -243,7 +250,7 @@ async function commandStart(options, settings = {}) {
243
250
  });
244
251
 
245
252
  const logFd = fs.openSync(options.logFile, "a");
246
- const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...options.rawFlags], {
253
+ const child = spawn(process.execPath, [SCRIPT_PATH, "foreground", ...runtimeForwardFlags(options.rawFlags)], {
247
254
  cwd: RUNTIME_ROOT,
248
255
  detached: true,
249
256
  env: process.env,
@@ -410,6 +417,8 @@ async function commandStatus(options) {
410
417
  console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
411
418
  console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
412
419
  console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
420
+ console.log(`Peers: ${state.peerEnabled ? state.peerUrl || "enabled" : "disabled"}`);
421
+ if (state.peerTlsFingerprint) console.log(`Peer TLS fingerprint: ${state.peerTlsFingerprint}`);
413
422
  console.log(`WebUI: ${formatDashboardUrl(dashboard)} (${webStatus})`);
414
423
  console.log(`Log: ${options.logFile}`);
415
424
  console.log(`WebUI log: ${options.webLogFile}`);
@@ -727,6 +736,11 @@ async function commandInit(options) {
727
736
  "OPENCLAW_DEFAULT_PROFILE=default",
728
737
  "CLAUDE_CODE_DEFAULT_PROFILE=default",
729
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",
730
744
  `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
731
745
  "TELEGRAM_TRANSPORT=polling",
732
746
  "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
@@ -755,6 +769,142 @@ async function createUserStore(home) {
755
769
  return new mod.UserStore(home);
756
770
  }
757
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
+
758
908
  function parseUserFlags(argv) {
759
909
  const copy = [...argv];
760
910
  const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
@@ -864,16 +1014,39 @@ async function commandDoctor(options) {
864
1014
  const userSnapshot = userStore?.snapshot();
865
1015
  const checks = [];
866
1016
  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"));
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"));
872
1040
  checks.push(check("User store", Boolean(userStore), userStore ? userStore.filePath : "missing runtime", userStore ? "pass" : "fail"));
873
1041
  checks.push(check("Admin user", Boolean(userSnapshot?.adminConfigured), userSnapshot?.adminConfigured ? "configured" : "missing"));
874
1042
  checks.push(check("WebUI login", true, "required for every dashboard request"));
875
1043
  checks.push(check("Telegram access", true, "requires linked active users and enabled group chats"));
876
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"));
877
1050
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
878
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"));
879
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"));
@@ -978,6 +1151,7 @@ async function checkOpenClawGateway() {
978
1151
  async function commandWeb(options) {
979
1152
  await mkdirp(options.home);
980
1153
  loadEnvFiles(options.home);
1154
+ await prepareRuntimeForLaunch(options);
981
1155
  await ensureConnectorStartedForWeb(options);
982
1156
  await startWebDashboard(options, { detached: false });
983
1157
  }
@@ -1077,6 +1251,7 @@ async function startWebDashboard(options, settings = {}) {
1077
1251
  async function commandForeground(options) {
1078
1252
  await mkdirp(options.home);
1079
1253
  loadEnvFiles(options.home);
1254
+ await prepareRuntimeForLaunch(options);
1080
1255
  process.chdir(RUNTIME_ROOT);
1081
1256
 
1082
1257
  await writeJsonAtomic(options.stateFile, {
@@ -1162,6 +1337,157 @@ async function resolveRuntimeEntry() {
1162
1337
  return null;
1163
1338
  }
1164
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
+
1165
1491
  async function resolveWebRuntimeEntry() {
1166
1492
  const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
1167
1493
  if (fs.existsSync(distEntry)) {
@@ -1307,6 +1633,10 @@ function isWindowsShellScript(filePath) {
1307
1633
  return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
1308
1634
  }
1309
1635
 
1636
+ function isLoopbackName(host) {
1637
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
1638
+ }
1639
+
1310
1640
  function validateStateBackend() {
1311
1641
  const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
1312
1642
  if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
@@ -1350,19 +1680,54 @@ function sleep(ms) {
1350
1680
  return new Promise((resolve) => setTimeout(resolve, ms));
1351
1681
  }
1352
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
+
1353
1712
  async function main() {
1354
1713
  const options = parseArgs(process.argv.slice(2));
1714
+ if (options.command === "help") {
1715
+ printHelp();
1716
+ return;
1717
+ }
1355
1718
  if (options.command === "start") return commandStart(options);
1356
1719
  if (options.command === "stop") return commandStop(options);
1357
1720
  if (options.command === "status") return commandStatus(options);
1358
1721
  if (options.command === "init") return commandInit(options);
1359
1722
  if (options.command === "user") return commandUser(options);
1723
+ if (options.command === "peer") return commandPeer(options);
1360
1724
  if (options.command === "doctor") return commandDoctor(options);
1361
1725
  if (options.command === "update") return commandUpdate(options);
1362
1726
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
1363
1727
  if (options.command === "restart") {
1364
1728
  await mkdirp(options.home);
1365
1729
  loadEnvFiles(options.home);
1730
+ await prepareRuntimeForLaunch(options);
1366
1731
  const webWasRunning = await isWebDashboardRunning(options);
1367
1732
  await commandStop(options);
1368
1733
  await commandStart(options);
@@ -1378,7 +1743,8 @@ async function main() {
1378
1743
  }
1379
1744
 
1380
1745
  console.error(`Unknown command: ${options.command}`);
1381
- 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.");
1382
1748
  process.exitCode = 2;
1383
1749
  }
1384
1750