@nordbyte/nordrelay 0.4.1 → 0.5.1

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 (57) hide show
  1. package/.env.example +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. package/dist/web-dashboard-style.js +0 -9
@@ -0,0 +1,83 @@
1
+ export function createZipBuffer(entries) {
2
+ const localParts = [];
3
+ const centralParts = [];
4
+ let offset = 0;
5
+ for (const entry of entries) {
6
+ const name = normalizeZipEntryName(entry.name);
7
+ const nameBuffer = Buffer.from(name, "utf8");
8
+ const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, "utf8");
9
+ const crc = crc32(data);
10
+ const date = entry.date ?? new Date();
11
+ const { dosTime, dosDate } = dateToDos(date);
12
+ const localHeader = Buffer.alloc(30);
13
+ localHeader.writeUInt32LE(0x04034b50, 0);
14
+ localHeader.writeUInt16LE(20, 4);
15
+ localHeader.writeUInt16LE(0x0800, 6);
16
+ localHeader.writeUInt16LE(0, 8);
17
+ localHeader.writeUInt16LE(dosTime, 10);
18
+ localHeader.writeUInt16LE(dosDate, 12);
19
+ localHeader.writeUInt32LE(crc, 14);
20
+ localHeader.writeUInt32LE(data.byteLength, 18);
21
+ localHeader.writeUInt32LE(data.byteLength, 22);
22
+ localHeader.writeUInt16LE(nameBuffer.byteLength, 26);
23
+ localHeader.writeUInt16LE(0, 28);
24
+ localParts.push(localHeader, nameBuffer, data);
25
+ const centralHeader = Buffer.alloc(46);
26
+ centralHeader.writeUInt32LE(0x02014b50, 0);
27
+ centralHeader.writeUInt16LE(20, 4);
28
+ centralHeader.writeUInt16LE(20, 6);
29
+ centralHeader.writeUInt16LE(0x0800, 8);
30
+ centralHeader.writeUInt16LE(0, 10);
31
+ centralHeader.writeUInt16LE(dosTime, 12);
32
+ centralHeader.writeUInt16LE(dosDate, 14);
33
+ centralHeader.writeUInt32LE(crc, 16);
34
+ centralHeader.writeUInt32LE(data.byteLength, 20);
35
+ centralHeader.writeUInt32LE(data.byteLength, 24);
36
+ centralHeader.writeUInt16LE(nameBuffer.byteLength, 28);
37
+ centralHeader.writeUInt16LE(0, 30);
38
+ centralHeader.writeUInt16LE(0, 32);
39
+ centralHeader.writeUInt16LE(0, 34);
40
+ centralHeader.writeUInt16LE(0, 36);
41
+ centralHeader.writeUInt32LE(0, 38);
42
+ centralHeader.writeUInt32LE(offset, 42);
43
+ centralParts.push(centralHeader, nameBuffer);
44
+ offset += localHeader.byteLength + nameBuffer.byteLength + data.byteLength;
45
+ }
46
+ const centralOffset = offset;
47
+ const centralDirectory = Buffer.concat(centralParts);
48
+ const end = Buffer.alloc(22);
49
+ end.writeUInt32LE(0x06054b50, 0);
50
+ end.writeUInt16LE(0, 4);
51
+ end.writeUInt16LE(0, 6);
52
+ end.writeUInt16LE(entries.length, 8);
53
+ end.writeUInt16LE(entries.length, 10);
54
+ end.writeUInt32LE(centralDirectory.byteLength, 12);
55
+ end.writeUInt32LE(centralOffset, 16);
56
+ end.writeUInt16LE(0, 20);
57
+ return Buffer.concat([...localParts, centralDirectory, end]);
58
+ }
59
+ function normalizeZipEntryName(name) {
60
+ return name.replace(/\\/g, "/").replace(/^\/+/, "");
61
+ }
62
+ function dateToDos(date) {
63
+ const year = Math.max(1980, date.getFullYear());
64
+ return {
65
+ dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
66
+ dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
67
+ };
68
+ }
69
+ const CRC32_TABLE = new Uint32Array(256);
70
+ for (let index = 0; index < CRC32_TABLE.length; index += 1) {
71
+ let value = index;
72
+ for (let bit = 0; bit < 8; bit += 1) {
73
+ value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
74
+ }
75
+ CRC32_TABLE[index] = value >>> 0;
76
+ }
77
+ function crc32(data) {
78
+ let crc = 0xffffffff;
79
+ for (const byte of data) {
80
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
81
+ }
82
+ return (crc ^ 0xffffffff) >>> 0;
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -39,9 +39,13 @@
39
39
  "docker-compose.yml"
40
40
  ],
41
41
  "scripts": {
42
- "build": "tsc",
43
- "check": "node --check plugins/nordrelay/scripts/nordrelay.mjs && tsc --noEmit",
42
+ "api:check": "node --import tsx scripts/generate-web-api-routes.mjs --check",
43
+ "api:generate": "node --import tsx scripts/generate-web-api-routes.mjs",
44
+ "build": "node scripts/clean-dist.mjs && npm run api:generate && tsc && node scripts/build-web-assets.mjs",
45
+ "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",
44
46
  "dev": "tsx src/index.ts",
47
+ "env:check": "node --import tsx scripts/generate-env-example.mjs --check",
48
+ "env:generate": "node --import tsx scripts/generate-env-example.mjs",
45
49
  "foreground": "node plugins/nordrelay/scripts/nordrelay.mjs foreground",
46
50
  "prepack": "npm run build",
47
51
  "prepublishOnly": "npm run check && npm test && npm run build",
@@ -50,7 +54,10 @@
50
54
  "status": "node plugins/nordrelay/scripts/nordrelay.mjs status",
51
55
  "start": "node plugins/nordrelay/scripts/nordrelay.mjs start",
52
56
  "stop": "node plugins/nordrelay/scripts/nordrelay.mjs stop",
53
- "test": "vitest run"
57
+ "test": "vitest run",
58
+ "test:e2e": "playwright test",
59
+ "test:all": "npm test && npm run test:e2e",
60
+ "webui:check": "tsc -p tsconfig.webui.json"
54
61
  },
55
62
  "dependencies": {
56
63
  "@anthropic-ai/claude-agent-sdk": "^0.2.140",
@@ -60,8 +67,10 @@
60
67
  "zod": "^4.4.3"
61
68
  },
62
69
  "devDependencies": {
70
+ "@playwright/test": "^1.60.0",
63
71
  "@types/better-sqlite3": "^7.6.0",
64
72
  "@types/node": "^25.5.0",
73
+ "esbuild": "^0.28.0",
65
74
  "tsx": "^4.21.0",
66
75
  "typescript": "^5.9.3",
67
76
  "vitest": "^3.2.4"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nordrelay",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Run a remote-control bridge for coding agents. Current adapters connect Codex, Pi, Hermes, and OpenClaw sessions to Telegram with streaming replies, multi-session controls, attachments, voice input, model selection, thread browsing, and handback.",
5
5
  "author": {
6
6
  "name": "Ricardo",
@@ -21,7 +21,7 @@ Codex plugin commands are namespaced by the plugin id in current plugin-aware co
21
21
  ## Workflow
22
22
 
23
23
  1. Locate the plugin root containing `.codex-plugin/plugin.json` with `"name": "nordrelay"`. In a source checkout this is usually `<repo>/plugins/nordrelay`.
24
- 2. Check whether `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_USER_IDS` are available from the environment or from the NordRelay env file.
24
+ 2. Check whether `TELEGRAM_BOT_TOKEN` is available from the environment or from the NordRelay env file, and whether a NordRelay admin user exists.
25
25
  3. Run the connector command from the plugin root:
26
26
 
27
27
  ```bash
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  import process from "node:process";
8
8
  import readline from "node:readline/promises";
9
9
  import { spawn } from "node:child_process";
10
- import { fileURLToPath } from "node:url";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
11
 
12
12
  const FALLBACK_VERSION = "0.3.1";
13
13
  const require = createRequire(import.meta.url);
@@ -61,7 +61,10 @@ function parseArgs(argv) {
61
61
  else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
62
62
  else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
63
63
  else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
64
- else if (arg === "--admin-id") options.telegramAdminUserIds = requireValue(copy, ++i, arg);
64
+ else if (arg === "--admin-email") options.adminEmail = requireValue(copy, ++i, arg);
65
+ else if (arg === "--admin-name") options.adminName = requireValue(copy, ++i, arg);
66
+ else if (arg === "--admin-password") options.adminPassword = requireValue(copy, ++i, arg);
67
+ else if (arg === "--telegram-user-id") options.telegramUserId = requireValue(copy, ++i, arg);
65
68
  else if (arg === "--state-backend") options.stateBackend = requireValue(copy, ++i, arg);
66
69
  else if (arg === "--enable-pi") options.enablePi = true;
67
70
  else if (arg === "--enable-hermes") options.enableHermes = true;
@@ -128,14 +131,6 @@ function loadEnvFile(envPath) {
128
131
  }
129
132
 
130
133
  function normalizeEnvAliases() {
131
- if (!process.env.TELEGRAM_ALLOWED_USER_IDS && process.env.TELEGRAM_ALLOWED_CHAT_IDS) {
132
- process.env.TELEGRAM_ALLOWED_USER_IDS = process.env.TELEGRAM_ALLOWED_CHAT_IDS;
133
- }
134
-
135
- if (!process.env.TELEGRAM_ALLOWED_CHAT_IDS && process.env.TELEGRAM_ALLOWED_USER_IDS) {
136
- process.env.TELEGRAM_ALLOWED_CHAT_IDS = process.env.TELEGRAM_ALLOWED_USER_IDS;
137
- }
138
-
139
134
  if (!process.env.TOOL_VERBOSITY && envFlag("NORDRELAY_FORWARD_TOOL_OUTPUT")) {
140
135
  process.env.TOOL_VERBOSITY = "all";
141
136
  }
@@ -334,85 +329,180 @@ async function commandStatus(options) {
334
329
  async function commandInit(options) {
335
330
  await mkdirp(options.home);
336
331
  const envPath = path.join(options.home, "nordrelay.env");
332
+ const userStore = await createUserStore(options.home);
337
333
  if (fs.existsSync(envPath) && !options.force) {
338
334
  console.log(`Config already exists: ${envPath}`);
339
335
  console.log("Run with --force to overwrite.");
340
336
  return;
341
337
  }
342
338
 
343
- const rl = process.stdin.isTTY
344
- ? readline.createInterface({ input: process.stdin, output: process.stdout })
345
- : null;
346
- try {
347
- const telegramBotToken = options.telegramBotToken ||
348
- process.env.TELEGRAM_BOT_TOKEN ||
349
- await ask(rl, "Telegram bot token", "");
350
- const telegramAdminUserIds = options.telegramAdminUserIds ||
351
- process.env.TELEGRAM_ADMIN_USER_IDS ||
352
- await ask(rl, "Telegram admin user id", "");
353
- const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
354
- const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
355
- const enableHermes = options.enableHermes ? "true" : await askChoice(rl, "Enable Hermes", "false");
356
- const enableOpenClaw = options.enableOpenClaw ? "true" : await askChoice(rl, "Enable OpenClaw", "false");
357
- const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(rl, "Enable Claude Code", "false");
358
- const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
359
-
360
- if (!telegramBotToken) throw new Error("Telegram bot token is required.");
361
- if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
362
- if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
363
- const defaultAgent = enableCodex === "true"
364
- ? "codex"
365
- : enablePi === "true"
366
- ? "pi"
367
- : enableHermes === "true"
368
- ? "hermes"
369
- : enableOpenClaw === "true"
370
- ? "openclaw"
371
- : "claude-code";
372
-
373
- const lines = [
374
- "# NordRelay local runtime config.",
375
- "# Keep this file private; it contains bot credentials.",
376
- `TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
377
- `TELEGRAM_ADMIN_USER_IDS=${telegramAdminUserIds}`,
378
- "TELEGRAM_ALLOW_ANY_CHAT=false",
379
- `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
380
- `NORDRELAY_PI_ENABLED=${enablePi}`,
381
- `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
382
- `NORDRELAY_OPENCLAW_ENABLED=${enableOpenClaw}`,
383
- `NORDRELAY_CLAUDE_CODE_ENABLED=${enableClaudeCode}`,
384
- `NORDRELAY_DEFAULT_AGENT=${defaultAgent}`,
385
- "PI_DEFAULT_PROFILE=default",
386
- "HERMES_API_BASE_URL=http://127.0.0.1:8642",
387
- "HERMES_DEFAULT_PROFILE=default",
388
- "OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789",
389
- "OPENCLAW_AGENT_ID=main",
390
- "OPENCLAW_DEFAULT_PROFILE=default",
391
- "CLAUDE_CODE_DEFAULT_PROFILE=default",
392
- "CLAUDE_CODE_MAX_TURNS=100",
393
- `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
394
- "TELEGRAM_TRANSPORT=polling",
395
- "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
396
- "",
397
- ];
398
-
399
- await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
400
- await fsp.chmod(envPath, 0o600).catch(() => {});
401
- console.log(`Wrote ${envPath}`);
402
- console.log("Run `nordrelay doctor` to validate the setup.");
403
- } finally {
404
- rl?.close();
339
+ const telegramBotToken = options.telegramBotToken ||
340
+ process.env.TELEGRAM_BOT_TOKEN ||
341
+ await ask(null, "Telegram bot token", "");
342
+ const adminEmail = options.adminEmail || await ask(null, "Admin email", "");
343
+ const adminName = options.adminName || await ask(null, "Admin name", "Admin");
344
+ const adminPassword = options.adminPassword || await askSecret(null, "Admin password", "");
345
+ const telegramUserId = options.telegramUserId || await ask(null, "Optional Telegram user id to link", "");
346
+ const enableCodex = options.disableCodex ? "false" : await askChoice(null, "Enable Codex", "true");
347
+ const enablePi = options.enablePi ? "true" : await askChoice(null, "Enable Pi", "false");
348
+ const enableHermes = options.enableHermes ? "true" : await askChoice(null, "Enable Hermes", "false");
349
+ const enableOpenClaw = options.enableOpenClaw ? "true" : await askChoice(null, "Enable OpenClaw", "false");
350
+ const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(null, "Enable Claude Code", "false");
351
+ const stateBackend = options.stateBackend || await askChoice(null, "State backend (json/sqlite)", "json");
352
+
353
+ if (!telegramBotToken) throw new Error("Telegram bot token is required.");
354
+ if (!adminEmail) throw new Error("Admin email is required.");
355
+ if (!adminPassword) throw new Error("Admin password is required.");
356
+ if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
357
+ const defaultAgent = enableCodex === "true"
358
+ ? "codex"
359
+ : enablePi === "true"
360
+ ? "pi"
361
+ : enableHermes === "true"
362
+ ? "hermes"
363
+ : enableOpenClaw === "true"
364
+ ? "openclaw"
365
+ : "claude-code";
366
+
367
+ const lines = [
368
+ "# NordRelay local runtime config.",
369
+ "# Keep this file private; it contains bot credentials.",
370
+ `TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
371
+ `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
372
+ `NORDRELAY_PI_ENABLED=${enablePi}`,
373
+ `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
374
+ `NORDRELAY_OPENCLAW_ENABLED=${enableOpenClaw}`,
375
+ `NORDRELAY_CLAUDE_CODE_ENABLED=${enableClaudeCode}`,
376
+ `NORDRELAY_DEFAULT_AGENT=${defaultAgent}`,
377
+ "PI_DEFAULT_PROFILE=default",
378
+ "HERMES_API_BASE_URL=http://127.0.0.1:8642",
379
+ "HERMES_DEFAULT_PROFILE=default",
380
+ "OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789",
381
+ "OPENCLAW_AGENT_ID=main",
382
+ "OPENCLAW_DEFAULT_PROFILE=default",
383
+ "CLAUDE_CODE_DEFAULT_PROFILE=default",
384
+ "CLAUDE_CODE_MAX_TURNS=100",
385
+ `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
386
+ "TELEGRAM_TRANSPORT=polling",
387
+ "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
388
+ "",
389
+ ];
390
+
391
+ await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
392
+ await fsp.chmod(envPath, 0o600).catch(() => {});
393
+ userStore.createAdmin({
394
+ email: adminEmail,
395
+ displayName: adminName || adminEmail,
396
+ password: adminPassword,
397
+ telegramUserId: telegramUserId ? Number(telegramUserId) : undefined,
398
+ });
399
+ console.log(`Wrote ${envPath}`);
400
+ console.log(`Created admin user ${adminEmail}.`);
401
+ console.log("Run `nordrelay doctor` to validate the setup.");
402
+ }
403
+
404
+ async function createUserStore(home) {
405
+ const modulePath = path.join(RUNTIME_ROOT, "dist", "user-management.js");
406
+ if (!fs.existsSync(modulePath)) {
407
+ throw new Error(`Missing user management runtime. Run \`npm run build\` in ${RUNTIME_ROOT}.`);
405
408
  }
409
+ const mod = await import(pathToFileURL(modulePath).href);
410
+ return new mod.UserStore(home);
411
+ }
412
+
413
+ function parseUserFlags(argv) {
414
+ const copy = [...argv];
415
+ const subcommand = copy[0] && !copy[0].startsWith("-") ? copy.shift() : "list";
416
+ const flags = { subcommand };
417
+ for (let i = 0; i < copy.length; i += 1) {
418
+ const arg = copy[i];
419
+ if (arg === "--email") flags.email = requireValue(copy, ++i, arg);
420
+ else if (arg === "--name") flags.name = requireValue(copy, ++i, arg);
421
+ else if (arg === "--password") flags.password = requireValue(copy, ++i, arg);
422
+ else if (arg === "--group" || arg === "--groups") flags.groups = requireValue(copy, ++i, arg);
423
+ else if (arg === "--telegram-user-id") flags.telegramUserId = Number.parseInt(requireValue(copy, ++i, arg), 10);
424
+ else if (arg === "--user-id") flags.userId = requireValue(copy, ++i, arg);
425
+ }
426
+ return flags;
427
+ }
428
+
429
+ async function commandUser(options) {
430
+ await mkdirp(options.home);
431
+ loadEnvFiles(options.home);
432
+ const store = await createUserStore(options.home);
433
+ const flags = parseUserFlags(options.rawFlags);
434
+ if (flags.subcommand === "list") {
435
+ const snapshot = store.snapshot();
436
+ if (snapshot.users.length === 0) {
437
+ console.log("No users configured.");
438
+ console.log("Create the first admin with `nordrelay user create-admin --email you@example.com --name YourName`.");
439
+ return;
440
+ }
441
+ for (const user of snapshot.users) {
442
+ console.log(`${user.email} (${user.displayName}) ${user.active ? "active" : "disabled"} groups=${user.groups.map((group) => group.id).join(",") || "-"}`);
443
+ }
444
+ return;
445
+ }
446
+
447
+ if (flags.subcommand === "create-admin" || flags.subcommand === "create") {
448
+ const email = flags.email || await ask(null, "Email", "");
449
+ const name = flags.name || await ask(null, "Display name", email);
450
+ const password = flags.password || await askSecret(null, "Password", "");
451
+ const groupIds = flags.subcommand === "create-admin"
452
+ ? ["admin"]
453
+ : (flags.groups ? flags.groups.split(",").map((item) => item.trim()).filter(Boolean) : ["user"]);
454
+ const created = flags.subcommand === "create-admin"
455
+ ? store.createAdmin({ email, displayName: name, password, telegramUserId: flags.telegramUserId })
456
+ : store.createUser({ email, displayName: name, password, groupIds, telegramUserId: flags.telegramUserId });
457
+ console.log(`Created user ${created.user.email} (${created.groups.map((group) => group.name).join(", ")}).`);
458
+ return;
459
+ }
460
+
461
+ if (flags.subcommand === "reset-password") {
462
+ const email = flags.email || await ask(null, "Email", "");
463
+ const password = flags.password || await askSecret(null, "New password", "");
464
+ const user = store.getUserByEmail(email);
465
+ if (!user) throw new Error(`User not found: ${email}`);
466
+ store.setPassword(user.user.id, password);
467
+ console.log(`Password updated for ${user.user.email}.`);
468
+ return;
469
+ }
470
+
471
+ if (flags.subcommand === "link-telegram") {
472
+ const email = flags.email || await ask(null, "Email", "");
473
+ const telegramUserId = flags.telegramUserId || Number.parseInt(await ask(null, "Telegram user id", ""), 10);
474
+ const user = store.getUserByEmail(email);
475
+ if (!user) throw new Error(`User not found: ${email}`);
476
+ store.linkTelegramUser(user.user.id, { telegramUserId });
477
+ console.log(`Linked Telegram user ${telegramUserId} to ${user.user.email}.`);
478
+ return;
479
+ }
480
+
481
+ if (flags.subcommand === "link-code") {
482
+ const email = flags.email || await ask(null, "Email", "");
483
+ const user = store.getUserByEmail(email);
484
+ if (!user) throw new Error(`User not found: ${email}`);
485
+ const code = store.createTelegramLinkCode(user.user.id);
486
+ console.log(`Telegram link code for ${user.user.email}: ${code.code}`);
487
+ console.log(`Expires: ${code.expiresAt}`);
488
+ return;
489
+ }
490
+
491
+ throw new Error("Usage: nordrelay user [list|create-admin|create|reset-password|link-telegram|link-code]");
406
492
  }
407
493
 
408
494
  async function commandDoctor(options) {
409
495
  await mkdirp(options.home);
410
496
  loadEnvFiles(options.home);
497
+ const userStore = await createUserStore(options.home).catch(() => null);
498
+ const userSnapshot = userStore?.snapshot();
411
499
  const checks = [];
412
500
  checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
413
501
  checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
414
- checks.push(check("Telegram admin ids", Boolean(process.env.TELEGRAM_ADMIN_USER_IDS), process.env.TELEGRAM_ADMIN_USER_IDS ? "configured" : "missing"));
415
- checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
502
+ checks.push(check("User store", Boolean(userStore), userStore ? userStore.filePath : "missing runtime", userStore ? "pass" : "fail"));
503
+ checks.push(check("Admin user", Boolean(userSnapshot?.adminConfigured), userSnapshot?.adminConfigured ? "configured" : "missing"));
504
+ checks.push(check("WebUI login", true, "required for every dashboard request"));
505
+ checks.push(check("Telegram access", true, "requires linked active users and enabled group chats"));
416
506
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
417
507
  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"));
418
508
  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"));
@@ -687,10 +777,68 @@ function findRuntimeRoot() {
687
777
  }
688
778
 
689
779
  async function ask(rl, label, defaultValue) {
690
- if (!rl) return defaultValue;
691
780
  const suffix = defaultValue ? ` [${defaultValue}]` : "";
692
- const answer = (await rl.question(`${label}${suffix}: `)).trim();
693
- return answer || defaultValue;
781
+ if (rl) {
782
+ const answer = (await rl.question(`${label}${suffix}: `)).trim();
783
+ return answer || defaultValue;
784
+ }
785
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue;
786
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
787
+ try {
788
+ const answer = (await prompt.question(`${label}${suffix}: `)).trim();
789
+ return answer || defaultValue;
790
+ } finally {
791
+ prompt.close();
792
+ }
793
+ }
794
+
795
+ async function askSecret(rl, label, defaultValue) {
796
+ void rl;
797
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue;
798
+ const suffix = defaultValue ? " [hidden default]" : "";
799
+ return await new Promise((resolve) => {
800
+ const input = process.stdin;
801
+ const output = process.stdout;
802
+ const wasRaw = input.isRaw;
803
+ let value = "";
804
+ output.write(`${label}${suffix}: `);
805
+ input.setRawMode(true);
806
+ input.resume();
807
+ const cleanup = () => {
808
+ input.off("data", onData);
809
+ input.setRawMode(Boolean(wasRaw));
810
+ input.pause();
811
+ };
812
+ const finish = () => {
813
+ cleanup();
814
+ output.write("\n");
815
+ resolve(value || defaultValue);
816
+ };
817
+ const onData = (chunk) => {
818
+ const text = chunk.toString("utf8");
819
+ for (const char of text) {
820
+ if (char === "\u0003") {
821
+ cleanup();
822
+ output.write("\n");
823
+ process.exit(130);
824
+ }
825
+ if (char === "\r" || char === "\n") {
826
+ finish();
827
+ return;
828
+ }
829
+ if (char === "\u007f" || char === "\b") {
830
+ if (value.length > 0) {
831
+ value = value.slice(0, -1);
832
+ output.write("\b \b");
833
+ }
834
+ continue;
835
+ }
836
+ value += char;
837
+ output.write("*");
838
+ }
839
+ };
840
+ input.on("data", onData);
841
+ });
694
842
  }
695
843
 
696
844
  async function askChoice(rl, label, defaultValue) {
@@ -770,6 +918,7 @@ async function main() {
770
918
  if (options.command === "stop") return commandStop(options);
771
919
  if (options.command === "status") return commandStatus(options);
772
920
  if (options.command === "init") return commandInit(options);
921
+ if (options.command === "user") return commandUser(options);
773
922
  if (options.command === "doctor") return commandDoctor(options);
774
923
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
775
924
  if (options.command === "restart") {
@@ -783,7 +932,7 @@ async function main() {
783
932
  }
784
933
 
785
934
  console.error(`Unknown command: ${options.command}`);
786
- console.error("Usage: nordrelay [init|doctor|web|start|stop|restart|status|foreground|version]");
935
+ console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|foreground|version]");
787
936
  process.exitCode = 2;
788
937
  }
789
938
 
@@ -21,6 +21,6 @@ node scripts/nordrelay.mjs status
21
21
  node scripts/nordrelay.mjs stop
22
22
  ```
23
23
 
24
- The bridge needs `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_USER_IDS` by default. Optional non-admin operators can be added with `TELEGRAM_ALLOWED_USER_IDS` or trusted group/topic access can be added with `TELEGRAM_ALLOWED_CHAT_IDS`.
24
+ The bridge needs `TELEGRAM_BOT_TOKEN` and at least one NordRelay admin user. Telegram accounts must be linked to active NordRelay users; group or forum chats must be enabled by an admin before they can control agents.
25
25
 
26
26
  Prefer `start` for normal use. Use `foreground` only when debugging connection problems, because it keeps the current command running. If the runtime is missing, run `npm install` and `npm run build` in the repository root.