@nordbyte/nordrelay 0.4.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -39,9 +39,11 @@
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
+ "build": "node scripts/clean-dist.mjs && tsc && node scripts/build-web-assets.mjs",
43
+ "check": "node --check plugins/nordrelay/scripts/nordrelay.mjs && tsc --noEmit && node scripts/build-web-assets.mjs --check && node --import tsx scripts/generate-env-example.mjs --check",
44
44
  "dev": "tsx src/index.ts",
45
+ "env:check": "node --import tsx scripts/generate-env-example.mjs --check",
46
+ "env:generate": "node --import tsx scripts/generate-env-example.mjs",
45
47
  "foreground": "node plugins/nordrelay/scripts/nordrelay.mjs foreground",
46
48
  "prepack": "npm run build",
47
49
  "prepublishOnly": "npm run check && npm test && npm run build",
@@ -62,6 +64,7 @@
62
64
  "devDependencies": {
63
65
  "@types/better-sqlite3": "^7.6.0",
64
66
  "@types/node": "^25.5.0",
67
+ "esbuild": "^0.28.0",
65
68
  "tsx": "^4.21.0",
66
69
  "typescript": "^5.9.3",
67
70
  "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);
@@ -49,8 +49,8 @@ function parseArgs(argv) {
49
49
  home: process.env.NORDRELAY_HOME || DEFAULT_HOME,
50
50
  dropPendingUpdates: !envFlag("NORDRELAY_KEEP_PENDING_UPDATES"),
51
51
  force: false,
52
- host: process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1",
53
- port: Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10),
52
+ host: undefined,
53
+ port: undefined,
54
54
  };
55
55
 
56
56
  for (let i = 0; i < copy.length; i += 1) {
@@ -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
  }
@@ -175,9 +170,31 @@ async function readPid(pidFile) {
175
170
  }
176
171
  }
177
172
 
178
- async function commandStart(options) {
173
+ function resolveDashboardEndpoint(options, settings = {}) {
174
+ const host = options.host || process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
175
+ const rawPort = options.port ?? Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
176
+ if (!Number.isFinite(rawPort) || rawPort <= 0) {
177
+ if (settings.strict) {
178
+ throw new Error("Dashboard port must be a positive number.");
179
+ }
180
+ return { host, port: 31878 };
181
+ }
182
+ const port = rawPort;
183
+ return { host, port };
184
+ }
185
+
186
+ function formatDashboardUrl(endpoint) {
187
+ const host = endpoint.host || "127.0.0.1";
188
+ const displayHost = host === "0.0.0.0" || host === "" ? "127.0.0.1" : host === "::" ? "::1" : host;
189
+ const formattedHost = displayHost.includes(":") && !displayHost.startsWith("[") ? `[${displayHost}]` : displayHost;
190
+ const bindHint = displayHost === host ? "" : ` (binds ${host || "all interfaces"})`;
191
+ return `http://${formattedHost}:${endpoint.port}/${bindHint}`;
192
+ }
193
+
194
+ async function commandStart(options, settings = {}) {
179
195
  await mkdirp(options.home);
180
196
  loadEnvFiles(options.home);
197
+ const dashboard = resolveDashboardEndpoint(options);
181
198
 
182
199
  const currentPid = await readPid(options.pidFile);
183
200
  if (isProcessRunning(currentPid)) {
@@ -210,6 +227,9 @@ async function commandStart(options) {
210
227
  console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
211
228
  console.log(`Workspace: ${state.workspace || "-"}`);
212
229
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
230
+ if (!settings.skipWebHint) {
231
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
232
+ }
213
233
  console.log(`Log: ${options.logFile}`);
214
234
  return;
215
235
  }
@@ -225,9 +245,27 @@ async function commandStart(options) {
225
245
  }
226
246
 
227
247
  console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
248
+ if (!settings.skipWebHint) {
249
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
250
+ }
228
251
  console.log(`Startup is still in progress. Log: ${options.logFile}`);
229
252
  }
230
253
 
254
+ async function ensureConnectorStartedForWeb(options) {
255
+ const currentPid = await readPid(options.pidFile);
256
+ if (isProcessRunning(currentPid)) {
257
+ console.log(`NordRelay connector already running with PID ${currentPid}.`);
258
+ return;
259
+ }
260
+
261
+ console.log("Starting NordRelay connector...");
262
+ const previousExitCode = process.exitCode;
263
+ await commandStart(options, { skipWebHint: true });
264
+ if (process.exitCode && process.exitCode !== previousExitCode) {
265
+ throw new Error(`NordRelay connector failed to start. See ${options.logFile}.`);
266
+ }
267
+ }
268
+
231
269
  async function waitForState(stateFile, pid, timeoutMs) {
232
270
  const deadline = Date.now() + timeoutMs;
233
271
  while (Date.now() < deadline) {
@@ -268,6 +306,7 @@ async function commandStop(options) {
268
306
 
269
307
  async function commandStatus(options) {
270
308
  loadEnvFiles(options.home);
309
+ const dashboard = resolveDashboardEndpoint(options);
271
310
  const pid = await readPid(options.pidFile);
272
311
  const state = await readJson(options.stateFile, {});
273
312
  const running = isProcessRunning(pid);
@@ -282,6 +321,7 @@ async function commandStatus(options) {
282
321
  console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
283
322
  console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
284
323
  console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
324
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)}`);
285
325
  console.log(`Log: ${options.logFile}`);
286
326
  if (state.error) console.log(`Error: ${state.error}`);
287
327
  }
@@ -289,85 +329,180 @@ async function commandStatus(options) {
289
329
  async function commandInit(options) {
290
330
  await mkdirp(options.home);
291
331
  const envPath = path.join(options.home, "nordrelay.env");
332
+ const userStore = await createUserStore(options.home);
292
333
  if (fs.existsSync(envPath) && !options.force) {
293
334
  console.log(`Config already exists: ${envPath}`);
294
335
  console.log("Run with --force to overwrite.");
295
336
  return;
296
337
  }
297
338
 
298
- const rl = process.stdin.isTTY
299
- ? readline.createInterface({ input: process.stdin, output: process.stdout })
300
- : null;
301
- try {
302
- const telegramBotToken = options.telegramBotToken ||
303
- process.env.TELEGRAM_BOT_TOKEN ||
304
- await ask(rl, "Telegram bot token", "");
305
- const telegramAdminUserIds = options.telegramAdminUserIds ||
306
- process.env.TELEGRAM_ADMIN_USER_IDS ||
307
- await ask(rl, "Telegram admin user id", "");
308
- const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
309
- const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
310
- const enableHermes = options.enableHermes ? "true" : await askChoice(rl, "Enable Hermes", "false");
311
- const enableOpenClaw = options.enableOpenClaw ? "true" : await askChoice(rl, "Enable OpenClaw", "false");
312
- const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(rl, "Enable Claude Code", "false");
313
- const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
314
-
315
- if (!telegramBotToken) throw new Error("Telegram bot token is required.");
316
- if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
317
- if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
318
- const defaultAgent = enableCodex === "true"
319
- ? "codex"
320
- : enablePi === "true"
321
- ? "pi"
322
- : enableHermes === "true"
323
- ? "hermes"
324
- : enableOpenClaw === "true"
325
- ? "openclaw"
326
- : "claude-code";
327
-
328
- const lines = [
329
- "# NordRelay local runtime config.",
330
- "# Keep this file private; it contains bot credentials.",
331
- `TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
332
- `TELEGRAM_ADMIN_USER_IDS=${telegramAdminUserIds}`,
333
- "TELEGRAM_ALLOW_ANY_CHAT=false",
334
- `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
335
- `NORDRELAY_PI_ENABLED=${enablePi}`,
336
- `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
337
- `NORDRELAY_OPENCLAW_ENABLED=${enableOpenClaw}`,
338
- `NORDRELAY_CLAUDE_CODE_ENABLED=${enableClaudeCode}`,
339
- `NORDRELAY_DEFAULT_AGENT=${defaultAgent}`,
340
- "PI_DEFAULT_PROFILE=default",
341
- "HERMES_API_BASE_URL=http://127.0.0.1:8642",
342
- "HERMES_DEFAULT_PROFILE=default",
343
- "OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789",
344
- "OPENCLAW_AGENT_ID=main",
345
- "OPENCLAW_DEFAULT_PROFILE=default",
346
- "CLAUDE_CODE_DEFAULT_PROFILE=default",
347
- "CLAUDE_CODE_MAX_TURNS=100",
348
- `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
349
- "TELEGRAM_TRANSPORT=polling",
350
- "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
351
- "",
352
- ];
353
-
354
- await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
355
- await fsp.chmod(envPath, 0o600).catch(() => {});
356
- console.log(`Wrote ${envPath}`);
357
- console.log("Run `nordrelay doctor` to validate the setup.");
358
- } finally {
359
- 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}.`);
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);
360
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]");
361
492
  }
362
493
 
363
494
  async function commandDoctor(options) {
364
495
  await mkdirp(options.home);
365
496
  loadEnvFiles(options.home);
497
+ const userStore = await createUserStore(options.home).catch(() => null);
498
+ const userSnapshot = userStore?.snapshot();
366
499
  const checks = [];
367
500
  checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
368
501
  checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
369
- checks.push(check("Telegram admin ids", Boolean(process.env.TELEGRAM_ADMIN_USER_IDS), process.env.TELEGRAM_ADMIN_USER_IDS ? "configured" : "missing"));
370
- 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"));
371
506
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
372
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"));
373
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"));
@@ -472,8 +607,8 @@ async function checkOpenClawGateway() {
472
607
  async function commandWeb(options) {
473
608
  await mkdirp(options.home);
474
609
  loadEnvFiles(options.home);
475
- const host = options.host || "127.0.0.1";
476
- const port = Number.isFinite(options.port) ? options.port : 31878;
610
+ const { host, port } = resolveDashboardEndpoint(options, { strict: true });
611
+ await ensureConnectorStartedForWeb(options);
477
612
  const entry = await resolveWebRuntimeEntry();
478
613
  if (!entry) {
479
614
  throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
@@ -564,21 +699,23 @@ async function commandForeground(options) {
564
699
  });
565
700
 
566
701
  const previousState = await readJson(options.stateFile, {});
702
+ const stoppedBySignal = exit.signal === "SIGTERM" || exit.signal === "SIGINT";
703
+ const stopped = exit.code === 0 || stoppedBySignal;
567
704
  await writeJsonAtomic(options.stateFile, {
568
- status: exit.code === 0 ? "stopped" : "error",
705
+ status: stopped ? "stopped" : "error",
569
706
  pid: process.pid,
570
707
  updatedAt: nowIso(),
571
708
  exitCode: exit.code,
572
709
  signal: exit.signal,
573
- error: exit.code === 0 ? undefined : previousState.error,
710
+ error: stopped ? undefined : previousState.error,
574
711
  logFile: options.logFile,
575
712
  });
576
713
 
577
- if (exit.signal) {
714
+ if (exit.signal && !stoppedBySignal) {
578
715
  process.kill(process.pid, exit.signal);
579
716
  return;
580
717
  }
581
- process.exit(exit.code ?? 0);
718
+ process.exit(stopped ? 0 : exit.code ?? 1);
582
719
  }
583
720
 
584
721
  async function resolveRuntimeEntry() {
@@ -640,10 +777,68 @@ function findRuntimeRoot() {
640
777
  }
641
778
 
642
779
  async function ask(rl, label, defaultValue) {
643
- if (!rl) return defaultValue;
644
780
  const suffix = defaultValue ? ` [${defaultValue}]` : "";
645
- const answer = (await rl.question(`${label}${suffix}: `)).trim();
646
- 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
+ });
647
842
  }
648
843
 
649
844
  async function askChoice(rl, label, defaultValue) {
@@ -723,6 +918,7 @@ async function main() {
723
918
  if (options.command === "stop") return commandStop(options);
724
919
  if (options.command === "status") return commandStatus(options);
725
920
  if (options.command === "init") return commandInit(options);
921
+ if (options.command === "user") return commandUser(options);
726
922
  if (options.command === "doctor") return commandDoctor(options);
727
923
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
728
924
  if (options.command === "restart") {
@@ -736,7 +932,7 @@ async function main() {
736
932
  }
737
933
 
738
934
  console.error(`Unknown command: ${options.command}`);
739
- 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]");
740
936
  process.exitCode = 2;
741
937
  }
742
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.