@nordbyte/nordrelay 0.8.2 → 0.8.3

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 (39) hide show
  1. package/README.md +4 -0
  2. package/dist/access/audit-log.js +30 -13
  3. package/dist/channels/discord/discord-bot.js +12 -27
  4. package/dist/channels/shared/channel-bridge-controller.js +1 -1
  5. package/dist/channels/shared/channel-prompt-queue.js +37 -0
  6. package/dist/channels/shared/channel-turn-service.js +23 -9
  7. package/dist/channels/slack/slack-bot.js +12 -15
  8. package/dist/channels/telegram/bot.js +18 -4
  9. package/dist/core/pagination.js +22 -0
  10. package/dist/peers/peer-store.js +16 -0
  11. package/dist/peers/peer-types.js +19 -0
  12. package/dist/peers/peer-web-proxy-contract.js +2 -0
  13. package/dist/runtime/relay-external-activity-monitor.js +15 -0
  14. package/dist/runtime/relay-queue-service.js +1 -0
  15. package/dist/runtime/relay-runtime-dashboard.js +3 -0
  16. package/dist/runtime/relay-runtime-helpers.js +3 -0
  17. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +14 -10
  18. package/dist/runtime/relay-runtime-sessions.js +8 -0
  19. package/dist/runtime/relay-runtime-trace.js +92 -0
  20. package/dist/runtime/relay-runtime-updates-jobs.js +11 -5
  21. package/dist/runtime/relay-runtime.js +16 -6
  22. package/dist/state/prompt-store.js +13 -1
  23. package/dist/web/web-api-contract.js +2 -0
  24. package/dist/web/web-dashboard-access-routes.js +15 -12
  25. package/dist/web/web-dashboard-artifact-routes.js +6 -2
  26. package/dist/web/web-dashboard-assets.js +1 -0
  27. package/dist/web/web-dashboard-pages.js +58 -20
  28. package/dist/web/web-dashboard-peer-routes.js +19 -0
  29. package/dist/web/web-dashboard-runtime-routes.js +8 -1
  30. package/dist/web/web-dashboard-session-routes.js +17 -12
  31. package/dist/web/web-dashboard-ui.js +46 -10
  32. package/dist/web/web-performance.js +2 -0
  33. package/dist/web/web-state.js +33 -4
  34. package/dist/webui-assets/dashboard.css +227 -39
  35. package/dist/webui-assets/dashboard.js +728 -58
  36. package/package.json +4 -2
  37. package/plugins/nordrelay/scripts/nordrelay.mjs +333 -8
  38. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  39. package/scripts/postinstall.mjs +122 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -34,6 +34,7 @@
34
34
  "files": [
35
35
  "dist/",
36
36
  "plugins/",
37
+ "scripts/postinstall.mjs",
37
38
  "scripts/launchd-start.sh",
38
39
  ".env.example",
39
40
  "Dockerfile",
@@ -43,11 +44,12 @@
43
44
  "api:check": "node --import tsx scripts/generate-web-api-routes.mjs --check",
44
45
  "api:generate": "node --import tsx scripts/generate-web-api-routes.mjs",
45
46
  "build": "node scripts/clean-dist.mjs && npm run api:generate && tsc && node scripts/build-web-assets.mjs",
46
- "check": "node --check plugins/nordrelay/scripts/nordrelay.mjs && npm run api:check && tsc --noEmit && npm run webui:check && node scripts/build-web-assets.mjs --check && node --import tsx scripts/generate-env-example.mjs --check && npm run size:check",
47
+ "check": "node --check plugins/nordrelay/scripts/nordrelay.mjs && node --check plugins/nordrelay/scripts/service-installer.mjs && npm run api:check && tsc --noEmit && npm run webui:check && node scripts/build-web-assets.mjs --check && node --import tsx scripts/generate-env-example.mjs --check && npm run size:check",
47
48
  "dev": "tsx src/index.ts",
48
49
  "env:check": "node --import tsx scripts/generate-env-example.mjs --check",
49
50
  "env:generate": "node --import tsx scripts/generate-env-example.mjs",
50
51
  "foreground": "node plugins/nordrelay/scripts/nordrelay.mjs foreground",
52
+ "postinstall": "node scripts/postinstall.mjs",
51
53
  "prepack": "npm run build",
52
54
  "prepublishOnly": "npm run check && npm test && npm run build",
53
55
  "security:audit": "npm audit --audit-level=high",
@@ -6,8 +6,15 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import process from "node:process";
8
8
  import readline from "node:readline/promises";
9
- import { spawn } from "node:child_process";
9
+ import { spawn, spawnSync } from "node:child_process";
10
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import {
12
+ buildLaunchdServiceSpec,
13
+ buildSystemdUserServiceSpec,
14
+ buildWindowsTaskServiceSpec,
15
+ parseServiceFlags,
16
+ serviceInstallSpec,
17
+ } from "./service-installer.mjs";
11
18
 
12
19
  const FALLBACK_VERSION = "0.3.1";
13
20
  const require = createRequire(import.meta.url);
@@ -238,6 +245,7 @@ function formatDashboardUrl(endpoint) {
238
245
  async function commandStart(options, settings = {}) {
239
246
  await mkdirp(options.home);
240
247
  loadEnvFiles(options.home);
248
+ warnIfCliPathMissing();
241
249
  await prepareRuntimeForLaunch(options);
242
250
  const dashboard = resolveDashboardEndpoint(options);
243
251
 
@@ -431,6 +439,46 @@ async function commandStatus(options) {
431
439
  if (state.error) console.log(`Error: ${state.error}`);
432
440
  }
433
441
 
442
+ function cliPathDiagnostics() {
443
+ const resolved = findExecutable(APP_NAME);
444
+ const globalBin = resolveNpmGlobalBinDir();
445
+ const candidate = globalBin ? path.join(globalBin, process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME) : null;
446
+ const pathContainsGlobalBin = globalBin ? pathListIncludes(globalBin) : false;
447
+ const expected = [candidate, SCRIPT_PATH].filter(Boolean);
448
+ const resolvedKnown = Boolean(resolved && expected.some((item) => pathsEqualOrLinked(resolved, item)));
449
+ const hint = globalBin
450
+ ? process.platform === "win32"
451
+ ? `Add ${globalBin} to PATH and reopen the terminal.`
452
+ : `Add ${globalBin} to PATH, for example: export PATH="${globalBin}:$PATH"`
453
+ : "Ensure the npm global bin directory is on PATH.";
454
+ return {
455
+ ok: Boolean(resolved),
456
+ resolved,
457
+ globalBin,
458
+ pathContainsGlobalBin,
459
+ expected: candidate,
460
+ resolvedKnown,
461
+ detail: resolved
462
+ ? resolvedKnown
463
+ ? resolved
464
+ : `${resolved} (different command target; current wrapper: ${SCRIPT_PATH})`
465
+ : `not found on PATH${globalBin ? `; npm global bin: ${globalBin}` : ""}`,
466
+ hint,
467
+ };
468
+ }
469
+
470
+ function warnIfCliPathMissing() {
471
+ if (envFlag("NORDRELAY_SUPPRESS_PATH_WARNING")) {
472
+ return;
473
+ }
474
+ const diagnostics = cliPathDiagnostics();
475
+ if (diagnostics.ok) {
476
+ return;
477
+ }
478
+ console.warn(`Warning: \`${APP_NAME}\` is not available on PATH.`);
479
+ console.warn(`Hint: ${diagnostics.hint}`);
480
+ }
481
+
434
482
  async function commandUpdate(options) {
435
483
  await mkdirp(options.home);
436
484
  loadEnvFiles(options.home);
@@ -671,6 +719,7 @@ function quoteWindowsCmdArg(value) {
671
719
 
672
720
  async function commandInit(options) {
673
721
  await mkdirp(options.home);
722
+ warnIfCliPathMissing();
674
723
  const envPath = path.join(options.home, "nordrelay.env");
675
724
  const userStore = await createUserStore(options.home);
676
725
  if (fs.existsSync(envPath) && !options.force) {
@@ -818,7 +867,7 @@ function parsePeerFlags(argv) {
818
867
  const copy = [...argv];
819
868
  const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
820
869
  const flags = { subcommand, url: undefined };
821
- if (["add", "test", "check", "revoke"].includes(subcommand) && copy[0] && !copy[0].startsWith("-")) {
870
+ if (["add", "test", "check", "revoke", "trust", "rotate"].includes(subcommand) && copy[0] && !copy[0].startsWith("-")) {
822
871
  flags.url = copy.shift();
823
872
  flags.id = flags.url;
824
873
  }
@@ -881,6 +930,7 @@ async function commandPeer(options) {
881
930
  if (peer.lastSeenAt) console.log(` Last seen: ${peer.lastSeenAt}`);
882
931
  if (peer.lastLatencyMs !== undefined) console.log(` Latency: ${peer.lastLatencyMs}ms`);
883
932
  if (peer.remoteVersion) console.log(` Remote version: ${peer.remoteVersion}`);
933
+ if (peer.trustStatus) console.log(` Trust: ${peer.trustStatus}${peer.trustWarnings?.length ? ` (${peer.trustWarnings.join("; ")})` : ""}`);
884
934
  if (peer.lastError) console.log(` Last error: ${peer.lastError}`);
885
935
  }
886
936
  return;
@@ -957,7 +1007,187 @@ async function commandPeer(options) {
957
1007
  return;
958
1008
  }
959
1009
 
960
- throw new Error("Usage: nordrelay peer [identity|list|invite|add|test|check|revoke]");
1010
+ if (flags.subcommand === "trust") {
1011
+ const id = flags.id || await ask(null, "Peer id", "");
1012
+ const peer = store.get(id);
1013
+ if (!peer?.url) throw new Error("Peer URL is required before TLS trust can be updated.");
1014
+ const probe = await clientMod.checkPeerIdentityEndpoint(peer.url, { timeoutMs: 5000 });
1015
+ if (!probe.ok || !probe.identity) throw new Error(`Peer identity could not be verified: ${probe.detail}`);
1016
+ if (probe.identity.nodeId !== peer.nodeId || probe.identity.publicKey !== peer.publicKey || probe.identity.fingerprint !== peer.fingerprint) {
1017
+ throw new Error("Peer identity changed. Re-pair this peer instead of trusting the TLS fingerprint.");
1018
+ }
1019
+ const updated = store.updatePeerTlsFingerprint(peer.id, probe.tlsFingerprint);
1020
+ console.log(`Trusted TLS fingerprint for ${updated.name}: ${updated.tlsFingerprint || "-"}`);
1021
+ return;
1022
+ }
1023
+
1024
+ if (flags.subcommand === "rotate") {
1025
+ const id = flags.id || await ask(null, "Peer id", "");
1026
+ 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"}`;
1027
+ const created = store.createRotationInvitation(id, { expiresInMs: Number.isFinite(flags.expiresMinutes) ? flags.expiresMinutes * 60 * 1000 : undefined });
1028
+ console.log(`Rotation invite for ${created.peer.name} (${created.peer.id}).`);
1029
+ console.log(`Pairing code: ${created.code}`);
1030
+ console.log(`Expires: ${created.invitation.expiresAt}`);
1031
+ console.log(`Command: nordrelay peer add ${url} --code ${created.code}`);
1032
+ return;
1033
+ }
1034
+
1035
+ throw new Error("Usage: nordrelay peer [identity|list|invite|add|test|check|trust|rotate|revoke]");
1036
+ }
1037
+
1038
+ async function commandService(options) {
1039
+ await mkdirp(options.home);
1040
+ loadEnvFiles(options.home);
1041
+ warnIfCliPathMissing();
1042
+ const flags = parseServiceFlags(options.rawFlags);
1043
+ const specOptions = { ...options, scriptPath: SCRIPT_PATH };
1044
+
1045
+ if (flags.subcommand === "install") {
1046
+ if (flags.dryRun) {
1047
+ printServiceInstallDryRun(specOptions, flags);
1048
+ return;
1049
+ }
1050
+ if (flags.platform === "darwin") {
1051
+ await installLaunchdService(specOptions, flags);
1052
+ return;
1053
+ }
1054
+ if (flags.platform === "win32") {
1055
+ await installWindowsTask(specOptions, flags);
1056
+ return;
1057
+ }
1058
+ await installSystemdUserService(specOptions, flags);
1059
+ return;
1060
+ }
1061
+
1062
+ if (flags.subcommand === "uninstall" || flags.subcommand === "remove") {
1063
+ if (flags.platform === "darwin") {
1064
+ await uninstallLaunchdService(flags);
1065
+ return;
1066
+ }
1067
+ if (flags.platform === "win32") {
1068
+ await uninstallWindowsTask(flags);
1069
+ return;
1070
+ }
1071
+ await uninstallSystemdUserService(flags);
1072
+ return;
1073
+ }
1074
+
1075
+ if (flags.subcommand === "status") {
1076
+ await commandServiceStatus(flags);
1077
+ return;
1078
+ }
1079
+
1080
+ throw new Error("Usage: nordrelay service [install|uninstall|status] [--no-start] [--name <name>] [--label <label>]");
1081
+ }
1082
+
1083
+ async function installSystemdUserService(options, flags) {
1084
+ const spec = buildSystemdUserServiceSpec(options, flags);
1085
+ const unitDir = path.dirname(spec.path);
1086
+ const unitPath = spec.path;
1087
+ await mkdirp(unitDir);
1088
+ await fsp.writeFile(unitPath, spec.content);
1089
+ console.log(`Installed systemd user service: ${unitPath}`);
1090
+ for (const command of spec.commands) {
1091
+ runPlatformCommand(command.command, command.args, command.label, command.settings);
1092
+ }
1093
+ console.log(`Status: nordrelay service status`);
1094
+ }
1095
+
1096
+ async function uninstallSystemdUserService(flags) {
1097
+ runPlatformCommand("systemctl", ["--user", "disable", "--now", `${flags.name}.service`], `Disable ${flags.name}.service`);
1098
+ const unitPath = path.join(os.homedir(), ".config", "systemd", "user", `${flags.name}.service`);
1099
+ await fsp.rm(unitPath, { force: true });
1100
+ runPlatformCommand("systemctl", ["--user", "daemon-reload"], "Reload systemd user units");
1101
+ console.log(`Removed systemd user service: ${unitPath}`);
1102
+ }
1103
+
1104
+ async function installLaunchdService(options, flags) {
1105
+ const spec = buildLaunchdServiceSpec(options, flags);
1106
+ const launchAgentsDir = path.dirname(spec.path);
1107
+ const plistPath = spec.path;
1108
+ await mkdirp(launchAgentsDir);
1109
+ await fsp.writeFile(plistPath, spec.content);
1110
+ console.log(`Installed launchd service: ${plistPath}`);
1111
+ for (const command of spec.commands) {
1112
+ runPlatformCommand(command.command, command.args, command.label, command.settings);
1113
+ }
1114
+ if (!flags.start) {
1115
+ const domain = launchdDomain();
1116
+ console.log(`Start later with: launchctl bootstrap ${domain} ${plistPath}`);
1117
+ }
1118
+ }
1119
+
1120
+ async function uninstallLaunchdService(flags) {
1121
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${flags.label}.plist`);
1122
+ const domain = `gui/${process.getuid?.() ?? ""}`;
1123
+ runPlatformCommand("launchctl", ["bootout", domain, plistPath], `Unload ${flags.label}`, { allowFailure: true });
1124
+ await fsp.rm(plistPath, { force: true });
1125
+ console.log(`Removed launchd service: ${plistPath}`);
1126
+ }
1127
+
1128
+ async function installWindowsTask(options, flags) {
1129
+ const spec = buildWindowsTaskServiceSpec(options, flags);
1130
+ for (const command of spec.commands) {
1131
+ runPlatformCommand(command.command, command.args, command.label, command.settings);
1132
+ }
1133
+ console.log(`Installed Windows task: ${flags.name}`);
1134
+ }
1135
+
1136
+ async function uninstallWindowsTask(flags) {
1137
+ runPlatformCommand("schtasks", ["/Delete", "/F", "/TN", flags.name], `Delete Windows task ${flags.name}`, { allowFailure: true });
1138
+ console.log(`Removed Windows task: ${flags.name}`);
1139
+ }
1140
+
1141
+ async function commandServiceStatus(flags) {
1142
+ if (process.platform === "darwin") {
1143
+ const domain = `gui/${process.getuid?.() ?? ""}`;
1144
+ runPlatformCommand("launchctl", ["print", `${domain}/${flags.label}`], `launchd status ${flags.label}`, { allowFailure: true });
1145
+ return;
1146
+ }
1147
+ if (process.platform === "win32") {
1148
+ runPlatformCommand("schtasks", ["/Query", "/TN", flags.name], `Windows task status ${flags.name}`, { allowFailure: true });
1149
+ return;
1150
+ }
1151
+ runPlatformCommand("systemctl", ["--user", "status", `${flags.name}.service`, "--no-pager"], `systemd user status ${flags.name}.service`, { allowFailure: true });
1152
+ }
1153
+
1154
+ function printServiceInstallDryRun(options, flags) {
1155
+ const spec = serviceInstallSpec(options, flags);
1156
+ console.log(`Service install dry-run (${spec.platform})`);
1157
+ console.log(`Target: ${spec.path}`);
1158
+ if (spec.content) {
1159
+ console.log("--- file content ---");
1160
+ console.log(spec.content.trimEnd());
1161
+ }
1162
+ console.log("--- commands ---");
1163
+ for (const command of spec.commands) {
1164
+ console.log(formatCommand(command.command, command.args));
1165
+ }
1166
+ }
1167
+
1168
+ function launchdDomain() {
1169
+ return `gui/${process.getuid?.() ?? ""}`;
1170
+ }
1171
+
1172
+ function runPlatformCommand(command, args, label, settings = {}) {
1173
+ const resolved = findExecutable(command);
1174
+ if (!resolved) {
1175
+ console.log(`${label}: ${command} not found. Run this step manually if this platform service manager is available.`);
1176
+ return false;
1177
+ }
1178
+ const useShell = isWindowsShellScript(resolved);
1179
+ console.log(`${label}: ${formatCommand(resolved, args)}`);
1180
+ const result = spawnSync(useShell ? formatShellCommand(resolved, args) : resolved, useShell ? [] : args, {
1181
+ cwd: RUNTIME_ROOT,
1182
+ env: process.env,
1183
+ shell: useShell,
1184
+ stdio: "inherit",
1185
+ windowsHide: false,
1186
+ });
1187
+ if (result.status !== 0 && !settings.allowFailure) {
1188
+ throw new Error(`${label} failed with exit code ${result.status ?? "unknown"}`);
1189
+ }
1190
+ return result.status === 0;
961
1191
  }
962
1192
 
963
1193
  function parseUserFlags(argv) {
@@ -1092,6 +1322,11 @@ async function commandDoctor(options) {
1092
1322
  const userSnapshot = userStore?.snapshot();
1093
1323
  const checks = [];
1094
1324
  checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
1325
+ const cliPath = cliPathDiagnostics();
1326
+ checks.push(check("NordRelay CLI on PATH", cliPath.ok, cliPath.ok ? cliPath.detail : `${cliPath.detail}; ${cliPath.hint}`, "warn"));
1327
+ if (cliPath.globalBin) {
1328
+ checks.push(check("npm global bin on PATH", cliPath.pathContainsGlobalBin, cliPath.globalBin, "warn"));
1329
+ }
1095
1330
  const telegramRequested = process.env.TELEGRAM_ENABLED !== "false";
1096
1331
  const discordRequested = process.env.DISCORD_ENABLED === "true";
1097
1332
  const slackRequested = process.env.SLACK_ENABLED === "true";
@@ -1245,11 +1480,20 @@ async function checkOpenClawGateway() {
1245
1480
  async function commandWeb(options) {
1246
1481
  await mkdirp(options.home);
1247
1482
  loadEnvFiles(options.home);
1483
+ warnIfCliPathMissing();
1248
1484
  await prepareRuntimeForLaunch(options);
1249
1485
  await ensureConnectorStartedForWeb(options);
1250
1486
  await startWebDashboard(options, { detached: false });
1251
1487
  }
1252
1488
 
1489
+ async function commandServiceRun(options) {
1490
+ await mkdirp(options.home);
1491
+ loadEnvFiles(options.home);
1492
+ await prepareRuntimeForLaunch(options);
1493
+ await ensureConnectorStartedForWeb(options);
1494
+ await startWebDashboard(options, { detached: false, stopConnectorOnExit: true });
1495
+ }
1496
+
1253
1497
  async function startWebDashboard(options, settings = {}) {
1254
1498
  await mkdirp(options.home);
1255
1499
  loadEnvFiles(options.home);
@@ -1335,6 +1579,9 @@ async function startWebDashboard(options, settings = {}) {
1335
1579
  exitCode: exit.code,
1336
1580
  signal: exit.signal,
1337
1581
  });
1582
+ if (settings.stopConnectorOnExit) {
1583
+ await commandStop(options, { keepWeb: true });
1584
+ }
1338
1585
  if (exit.signal) {
1339
1586
  process.kill(process.pid, exit.signal);
1340
1587
  return;
@@ -1723,6 +1970,70 @@ function findExecutable(command, pathValue = process.env.PATH, pathextValue = pr
1723
1970
  return null;
1724
1971
  }
1725
1972
 
1973
+ function resolveNpmGlobalBinDir(env = process.env) {
1974
+ const prefix = resolveNpmGlobalPrefix(env);
1975
+ if (!prefix) {
1976
+ return null;
1977
+ }
1978
+ return process.platform === "win32" ? prefix : path.join(prefix, "bin");
1979
+ }
1980
+
1981
+ function resolveNpmGlobalPrefix(env = process.env) {
1982
+ if (env.npm_config_prefix) {
1983
+ return path.resolve(env.npm_config_prefix);
1984
+ }
1985
+ const npm = resolveNpmSpawnCommand(env);
1986
+ if (!npm) {
1987
+ return null;
1988
+ }
1989
+ const command = npm.shell
1990
+ ? formatShellCommand(npm.command, [...npm.argsPrefix, "prefix", "-g"])
1991
+ : npm.command;
1992
+ const args = npm.shell ? [] : [...npm.argsPrefix, "prefix", "-g"];
1993
+ const result = spawnSync(command, args, {
1994
+ cwd: os.homedir(),
1995
+ env,
1996
+ shell: npm.shell,
1997
+ encoding: "utf8",
1998
+ windowsHide: true,
1999
+ timeout: 5000,
2000
+ });
2001
+ if (result.status !== 0) {
2002
+ return null;
2003
+ }
2004
+ const prefix = String(result.stdout || "").trim().split(/\r?\n/).at(-1)?.trim();
2005
+ return prefix ? path.resolve(prefix) : null;
2006
+ }
2007
+
2008
+ function pathListIncludes(directory, pathValue = process.env.PATH) {
2009
+ const normalized = normalizePathForCompare(directory);
2010
+ return (pathValue || "")
2011
+ .split(path.delimiter)
2012
+ .filter(Boolean)
2013
+ .some((entry) => normalizePathForCompare(entry) === normalized);
2014
+ }
2015
+
2016
+ function pathsEqualOrLinked(left, right) {
2017
+ if (!left || !right) {
2018
+ return false;
2019
+ }
2020
+ const normalizedLeft = normalizePathForCompare(left);
2021
+ const normalizedRight = normalizePathForCompare(right);
2022
+ if (normalizedLeft === normalizedRight) {
2023
+ return true;
2024
+ }
2025
+ try {
2026
+ return normalizePathForCompare(fs.realpathSync(left)) === normalizePathForCompare(fs.realpathSync(right));
2027
+ } catch {
2028
+ return false;
2029
+ }
2030
+ }
2031
+
2032
+ function normalizePathForCompare(value) {
2033
+ const resolved = path.resolve(value || "");
2034
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
2035
+ }
2036
+
1726
2037
  function windowsExecutableExtensions(pathextValue) {
1727
2038
  const pathext = (pathextValue || ".COM;.EXE;.BAT;.CMD")
1728
2039
  .split(";")
@@ -1792,6 +2103,7 @@ function printHelp() {
1792
2103
  console.log(" init Create local config and first admin user");
1793
2104
  console.log(" user Manage users, groups, and channel links");
1794
2105
  console.log(" peer Manage secure NordRelay peer federation");
2106
+ console.log(" service Install, remove, or inspect the OS service");
1795
2107
  console.log(" doctor Validate the local setup");
1796
2108
  console.log(" web, dashboard Start the WebUI and connector");
1797
2109
  console.log(" start Start the connector");
@@ -1806,6 +2118,7 @@ function printHelp() {
1806
2118
  console.log(" --home <path> Runtime home directory");
1807
2119
  console.log(" --host <host> WebUI bind host");
1808
2120
  console.log(" --port <port> WebUI port");
2121
+ console.log(" service install --dry-run [--platform linux|darwin|win32]");
1809
2122
  console.log(" --build Build source runtime before start/web/restart");
1810
2123
  console.log(" --force Overwrite existing config during init");
1811
2124
  console.log(" --help, -h Show this help");
@@ -1824,6 +2137,7 @@ async function main() {
1824
2137
  if (options.command === "init") return commandInit(options);
1825
2138
  if (options.command === "user") return commandUser(options);
1826
2139
  if (options.command === "peer") return commandPeer(options);
2140
+ if (options.command === "service") return commandService(options);
1827
2141
  if (options.command === "doctor") return commandDoctor(options);
1828
2142
  if (options.command === "update") return commandUpdate(options);
1829
2143
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
@@ -1840,18 +2154,29 @@ async function main() {
1840
2154
  return;
1841
2155
  }
1842
2156
  if (options.command === "foreground") return commandForeground(options);
2157
+ if (options.command === "service-run") return commandServiceRun(options);
1843
2158
  if (options.command === "--version" || options.command === "version") {
1844
2159
  console.log(`${APP_NAME} ${VERSION}`);
1845
2160
  return;
1846
2161
  }
1847
2162
 
1848
2163
  console.error(`Unknown command: ${options.command}`);
1849
- console.error("Usage: nordrelay [init|user|peer|doctor|web|start|stop|restart|status|update|foreground|version]");
2164
+ console.error("Usage: nordrelay [init|user|peer|service|doctor|web|start|stop|restart|status|update|foreground|version]");
1850
2165
  console.error("Run `nordrelay --help` for details.");
1851
2166
  process.exitCode = 2;
1852
2167
  }
1853
2168
 
1854
- main().catch((error) => {
1855
- console.error(error instanceof Error ? error.message : String(error));
1856
- process.exitCode = 1;
1857
- });
2169
+ export {
2170
+ buildLaunchdServiceSpec,
2171
+ buildSystemdUserServiceSpec,
2172
+ buildWindowsTaskServiceSpec,
2173
+ parseServiceFlags,
2174
+ serviceInstallSpec,
2175
+ };
2176
+
2177
+ if (process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH) {
2178
+ main().catch((error) => {
2179
+ console.error(error instanceof Error ? error.message : String(error));
2180
+ process.exitCode = 1;
2181
+ });
2182
+ }
@@ -0,0 +1,183 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const DEFAULT_SCRIPT_PATH = fileURLToPath(new URL("./nordrelay.mjs", import.meta.url));
7
+
8
+ function requireValue(argv, index, flag) {
9
+ const value = argv[index];
10
+ if (!value || value.startsWith("-")) {
11
+ throw new Error(`${flag} requires a value`);
12
+ }
13
+ return value;
14
+ }
15
+
16
+ function parseServiceFlags(argv) {
17
+ const copy = [...argv];
18
+ const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "status";
19
+ const flags = {
20
+ subcommand,
21
+ start: true,
22
+ name: process.platform === "win32" ? "NordRelay" : "nordrelay",
23
+ label: "io.nordbyte.nordrelay",
24
+ dryRun: false,
25
+ platform: process.platform,
26
+ };
27
+ for (let i = 0; i < copy.length; i += 1) {
28
+ const arg = copy[i];
29
+ if (arg === "--no-start") flags.start = false;
30
+ else if (arg === "--dry-run") flags.dryRun = true;
31
+ else if (arg === "--platform") flags.platform = requireValue(copy, ++i, arg);
32
+ else if (arg === "--name") flags.name = requireValue(copy, ++i, arg);
33
+ else if (arg === "--label") flags.label = requireValue(copy, ++i, arg);
34
+ }
35
+ return flags;
36
+ }
37
+
38
+ function serviceRunArgs(options) {
39
+ const args = ["service-run", "--home", options.home];
40
+ if (options.host) args.push("--host", options.host);
41
+ if (options.port) args.push("--port", String(options.port));
42
+ return args;
43
+ }
44
+
45
+ function buildSystemdUserServiceSpec(options, flags) {
46
+ const scriptPath = options.scriptPath || DEFAULT_SCRIPT_PATH;
47
+ const unitPath = path.join(os.homedir(), ".config", "systemd", "user", `${flags.name}.service`);
48
+ const execStart = [process.execPath, scriptPath, ...serviceRunArgs(options)].map(systemdQuote).join(" ");
49
+ const content = [
50
+ "[Unit]",
51
+ "Description=NordRelay connector and WebUI",
52
+ "After=network-online.target",
53
+ "",
54
+ "[Service]",
55
+ "Type=simple",
56
+ `ExecStart=${execStart}`,
57
+ "Restart=on-failure",
58
+ "RestartSec=5",
59
+ `Environment=NORDRELAY_HOME=${systemdQuote(options.home)}`,
60
+ "",
61
+ "[Install]",
62
+ "WantedBy=default.target",
63
+ "",
64
+ ].join("\n");
65
+ const action = flags.start ? "enable --now" : "enable";
66
+ return {
67
+ platform: "linux",
68
+ path: unitPath,
69
+ content,
70
+ commands: [
71
+ { command: "systemctl", args: ["--user", "daemon-reload"], label: "Reload systemd user units" },
72
+ { command: "systemctl", args: ["--user", "enable", flags.start ? "--now" : "", `${flags.name}.service`].filter(Boolean), label: `systemctl --user ${action} ${flags.name}.service` },
73
+ ],
74
+ };
75
+ }
76
+
77
+ function buildLaunchdServiceSpec(options, flags) {
78
+ const scriptPath = options.scriptPath || DEFAULT_SCRIPT_PATH;
79
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${flags.label}.plist`);
80
+ const domain = launchdDomain();
81
+ const commands = [
82
+ { command: "launchctl", args: ["bootout", domain, plistPath], label: `Unload existing ${flags.label}`, settings: { allowFailure: true } },
83
+ ];
84
+ if (flags.start) {
85
+ commands.push(
86
+ { command: "launchctl", args: ["bootstrap", domain, plistPath], label: `Load ${flags.label}` },
87
+ { command: "launchctl", args: ["enable", `${domain}/${flags.label}`], label: `Enable ${flags.label}`, settings: { allowFailure: true } },
88
+ { command: "launchctl", args: ["kickstart", "-k", `${domain}/${flags.label}`], label: `Start ${flags.label}`, settings: { allowFailure: true } },
89
+ );
90
+ }
91
+ return {
92
+ platform: "darwin",
93
+ path: plistPath,
94
+ content: launchdPlist(flags.label, process.execPath, [scriptPath, ...serviceRunArgs(options)], options.home),
95
+ commands,
96
+ };
97
+ }
98
+
99
+ function buildWindowsTaskServiceSpec(options, flags) {
100
+ const scriptPath = options.scriptPath || DEFAULT_SCRIPT_PATH;
101
+ const taskCommand = windowsTaskCommand(process.execPath, [scriptPath, ...serviceRunArgs(options)]);
102
+ const commands = [
103
+ { command: "schtasks", args: ["/Create", "/F", "/SC", "ONLOGON", "/TN", flags.name, "/TR", taskCommand], label: `Create Windows task ${flags.name}` },
104
+ ];
105
+ if (flags.start) {
106
+ commands.push({ command: "schtasks", args: ["/Run", "/TN", flags.name], label: `Start Windows task ${flags.name}`, settings: { allowFailure: true } });
107
+ }
108
+ return {
109
+ platform: "win32",
110
+ path: flags.name,
111
+ content: "",
112
+ commands,
113
+ };
114
+ }
115
+
116
+ function serviceInstallSpec(options, flags) {
117
+ if (flags.platform === "darwin") return buildLaunchdServiceSpec(options, flags);
118
+ if (flags.platform === "win32") return buildWindowsTaskServiceSpec(options, flags);
119
+ return buildSystemdUserServiceSpec(options, flags);
120
+ }
121
+
122
+ function systemdQuote(value) {
123
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
124
+ }
125
+
126
+ function launchdDomain() {
127
+ return `gui/${process.getuid?.() ?? ""}`;
128
+ }
129
+
130
+ function launchdPlist(label, command, args, home) {
131
+ const programArguments = [command, ...args]
132
+ .map((value) => ` <string>${xmlEscape(value)}</string>`)
133
+ .join("\n");
134
+ return [
135
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
136
+ "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">",
137
+ "<plist version=\"1.0\">",
138
+ "<dict>",
139
+ " <key>Label</key>",
140
+ ` <string>${xmlEscape(label)}</string>`,
141
+ " <key>ProgramArguments</key>",
142
+ " <array>",
143
+ programArguments,
144
+ " </array>",
145
+ " <key>EnvironmentVariables</key>",
146
+ " <dict>",
147
+ " <key>NORDRELAY_HOME</key>",
148
+ ` <string>${xmlEscape(home)}</string>`,
149
+ " </dict>",
150
+ " <key>RunAtLoad</key>",
151
+ " <true/>",
152
+ " <key>KeepAlive</key>",
153
+ " <true/>",
154
+ " <key>StandardOutPath</key>",
155
+ ` <string>${xmlEscape(path.join(home, "service.log"))}</string>`,
156
+ " <key>StandardErrorPath</key>",
157
+ ` <string>${xmlEscape(path.join(home, "service.log"))}</string>`,
158
+ "</dict>",
159
+ "</plist>",
160
+ "",
161
+ ].join("\n");
162
+ }
163
+
164
+ function windowsTaskCommand(command, args) {
165
+ return [command, ...args].map((part) => `"${String(part).replace(/"/g, '""')}"`).join(" ");
166
+ }
167
+
168
+ function xmlEscape(value) {
169
+ return String(value)
170
+ .replace(/&/g, "&amp;")
171
+ .replace(/</g, "&lt;")
172
+ .replace(/>/g, "&gt;")
173
+ .replace(/"/g, "&quot;")
174
+ .replace(/'/g, "&apos;");
175
+ }
176
+
177
+ export {
178
+ buildLaunchdServiceSpec,
179
+ buildSystemdUserServiceSpec,
180
+ buildWindowsTaskServiceSpec,
181
+ parseServiceFlags,
182
+ serviceInstallSpec,
183
+ };