@nordbyte/nordrelay 0.2.1 → 0.3.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.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -30,6 +30,7 @@
30
30
  "dist/",
31
31
  "plugins/",
32
32
  "launchd/",
33
+ "CHANGELOG.md",
33
34
  ".env.example",
34
35
  "Dockerfile",
35
36
  "docker-compose.yml"
@@ -41,6 +42,8 @@
41
42
  "foreground": "node plugins/nordrelay/scripts/nordrelay.mjs foreground",
42
43
  "prepack": "npm run build",
43
44
  "prepublishOnly": "npm run check && npm test && npm run build",
45
+ "security:audit": "npm audit --audit-level=high",
46
+ "sbom": "npm sbom --json > sbom.json",
44
47
  "status": "node plugins/nordrelay/scripts/nordrelay.mjs status",
45
48
  "start": "node plugins/nordrelay/scripts/nordrelay.mjs start",
46
49
  "stop": "node plugins/nordrelay/scripts/nordrelay.mjs stop",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nordrelay",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Run a remote-control bridge for coding agents. The current adapter connects Codex 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",
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import fsp from "node:fs/promises";
4
+ import { createRequire } from "node:module";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
7
  import process from "node:process";
8
+ import readline from "node:readline/promises";
7
9
  import { spawn } from "node:child_process";
8
10
  import { fileURLToPath } from "node:url";
9
11
 
10
- const VERSION = "0.2.1";
12
+ const VERSION = "0.3.0";
13
+ const require = createRequire(import.meta.url);
11
14
  const APP_NAME = "nordrelay";
12
15
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
13
16
  const PLUGIN_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
@@ -31,12 +34,23 @@ function parseArgs(argv) {
31
34
  rawFlags: copy,
32
35
  home: process.env.NORDRELAY_HOME || DEFAULT_HOME,
33
36
  dropPendingUpdates: !envFlag("NORDRELAY_KEEP_PENDING_UPDATES"),
37
+ force: false,
38
+ host: process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1",
39
+ port: Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10),
34
40
  };
35
41
 
36
42
  for (let i = 0; i < copy.length; i += 1) {
37
43
  const arg = copy[i];
38
44
  if (arg === "--home") options.home = requireValue(copy, ++i, arg);
39
45
  else if (arg === "--keep-pending-updates") options.dropPendingUpdates = false;
46
+ else if (arg === "--force") options.force = true;
47
+ else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
48
+ else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
49
+ else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
50
+ else if (arg === "--admin-id") options.telegramAdminUserIds = requireValue(copy, ++i, arg);
51
+ else if (arg === "--state-backend") options.stateBackend = requireValue(copy, ++i, arg);
52
+ else if (arg === "--enable-pi") options.enablePi = true;
53
+ else if (arg === "--disable-codex") options.disableCodex = true;
40
54
  }
41
55
 
42
56
  options.pidFile = path.join(options.home, "nordrelay.pid");
@@ -254,6 +268,124 @@ async function commandStatus(options) {
254
268
  if (state.error) console.log(`Error: ${state.error}`);
255
269
  }
256
270
 
271
+ async function commandInit(options) {
272
+ await mkdirp(options.home);
273
+ const envPath = path.join(options.home, "nordrelay.env");
274
+ if (fs.existsSync(envPath) && !options.force) {
275
+ console.log(`Config already exists: ${envPath}`);
276
+ console.log("Run with --force to overwrite.");
277
+ return;
278
+ }
279
+
280
+ const rl = process.stdin.isTTY
281
+ ? readline.createInterface({ input: process.stdin, output: process.stdout })
282
+ : null;
283
+ try {
284
+ const telegramBotToken = options.telegramBotToken ||
285
+ process.env.TELEGRAM_BOT_TOKEN ||
286
+ await ask(rl, "Telegram bot token", "");
287
+ const telegramAdminUserIds = options.telegramAdminUserIds ||
288
+ process.env.TELEGRAM_ADMIN_USER_IDS ||
289
+ await ask(rl, "Telegram admin user id", "");
290
+ const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
291
+ const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
292
+ const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
293
+
294
+ if (!telegramBotToken) throw new Error("Telegram bot token is required.");
295
+ if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
296
+ if (enableCodex !== "true" && enablePi !== "true") throw new Error("At least one agent must be enabled.");
297
+
298
+ const lines = [
299
+ "# NordRelay local runtime config.",
300
+ "# Keep this file private; it contains bot credentials.",
301
+ `TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
302
+ `TELEGRAM_ADMIN_USER_IDS=${telegramAdminUserIds}`,
303
+ "TELEGRAM_ALLOW_ANY_CHAT=false",
304
+ `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
305
+ `NORDRELAY_PI_ENABLED=${enablePi}`,
306
+ `NORDRELAY_DEFAULT_AGENT=${enableCodex === "true" ? "codex" : "pi"}`,
307
+ `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
308
+ "TELEGRAM_TRANSPORT=polling",
309
+ "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
310
+ "",
311
+ ];
312
+
313
+ await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
314
+ await fsp.chmod(envPath, 0o600).catch(() => {});
315
+ console.log(`Wrote ${envPath}`);
316
+ console.log("Run `nordrelay doctor` to validate the setup.");
317
+ } finally {
318
+ rl?.close();
319
+ }
320
+ }
321
+
322
+ async function commandDoctor(options) {
323
+ await mkdirp(options.home);
324
+ loadEnvFiles(options.home);
325
+ const checks = [];
326
+ checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
327
+ checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
328
+ checks.push(check("Telegram admin ids", Boolean(process.env.TELEGRAM_ADMIN_USER_IDS), process.env.TELEGRAM_ADMIN_USER_IDS ? "configured" : "missing"));
329
+ checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
330
+ checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
331
+ 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"));
332
+ checks.push(check("Codex CLI", Boolean(findExecutable(process.env.CODEX_CLI_PATH || "codex")), process.env.CODEX_CLI_PATH || findExecutable("codex") || "not found", process.env.NORDRELAY_CODEX_ENABLED === "false" ? "warn" : "fail"));
333
+ checks.push(check("Pi CLI", Boolean(findExecutable(process.env.PI_CLI_PATH || "pi")), process.env.PI_CLI_PATH || findExecutable("pi") || "not found", process.env.NORDRELAY_PI_ENABLED === "true" ? "fail" : "warn"));
334
+ checks.push(check("ffmpeg", Boolean(findExecutable("ffmpeg")), findExecutable("ffmpeg") || "not found", "warn"));
335
+ checks.push(check("State backend", validateStateBackend(), `NORDRELAY_STATE_BACKEND=${process.env.NORDRELAY_STATE_BACKEND ?? "json"}`));
336
+ checks.push(check("Runtime entry", Boolean(await resolveRuntimeEntry()), RUNTIME_ROOT));
337
+
338
+ for (const item of checks) {
339
+ console.log(`${item.icon} ${item.name}: ${item.detail}`);
340
+ }
341
+
342
+ const failed = checks.filter((item) => item.status === "fail" && !item.ok);
343
+ const warned = checks.filter((item) => item.status === "warn" && !item.ok);
344
+ console.log(`\nSummary: ${failed.length} failed, ${warned.length} warnings.`);
345
+ if (failed.length > 0) process.exitCode = 1;
346
+ }
347
+
348
+ async function commandWeb(options) {
349
+ await mkdirp(options.home);
350
+ loadEnvFiles(options.home);
351
+ const host = options.host || "127.0.0.1";
352
+ const port = Number.isFinite(options.port) ? options.port : 31878;
353
+ const entry = await resolveWebRuntimeEntry();
354
+ if (!entry) {
355
+ throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
356
+ }
357
+
358
+ const env = {
359
+ ...process.env,
360
+ NORDRELAY_HOME: options.home,
361
+ NORDRELAY_SOURCE_ROOT: RUNTIME_ROOT,
362
+ NORDRELAY_DASHBOARD_HOST: host,
363
+ NORDRELAY_DASHBOARD_PORT: String(port),
364
+ };
365
+ const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
366
+ cwd: RUNTIME_ROOT,
367
+ env,
368
+ stdio: "inherit",
369
+ });
370
+
371
+ const forwardSignal = (signal) => {
372
+ if (isProcessRunning(child.pid)) {
373
+ child.kill(signal);
374
+ }
375
+ };
376
+ process.once("SIGINT", () => forwardSignal("SIGINT"));
377
+ process.once("SIGTERM", () => forwardSignal("SIGTERM"));
378
+
379
+ const exit = await new Promise((resolve) => {
380
+ child.once("exit", (code, signal) => resolve({ code, signal }));
381
+ });
382
+ if (exit.signal) {
383
+ process.kill(process.pid, exit.signal);
384
+ return;
385
+ }
386
+ process.exit(exit.code ?? 0);
387
+ }
388
+
257
389
  async function commandForeground(options) {
258
390
  await mkdirp(options.home);
259
391
  loadEnvFiles(options.home);
@@ -338,6 +470,21 @@ async function resolveRuntimeEntry() {
338
470
  return null;
339
471
  }
340
472
 
473
+ async function resolveWebRuntimeEntry() {
474
+ const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
475
+ if (fs.existsSync(distEntry)) {
476
+ return { command: process.execPath, args: [distEntry] };
477
+ }
478
+
479
+ const tsEntry = path.join(RUNTIME_ROOT, "src", "web-dashboard.ts");
480
+ const tsxBin = path.join(RUNTIME_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
481
+ if (fs.existsSync(tsEntry) && fs.existsSync(tsxBin)) {
482
+ return { command: tsxBin, args: [tsEntry] };
483
+ }
484
+
485
+ return null;
486
+ }
487
+
341
488
  function findRuntimeRoot() {
342
489
  const candidates = [
343
490
  process.env.NORDRELAY_SOURCE_ROOT,
@@ -366,6 +513,53 @@ function findRuntimeRoot() {
366
513
  return DEFAULT_MARKETPLACE_ROOT;
367
514
  }
368
515
 
516
+ async function ask(rl, label, defaultValue) {
517
+ if (!rl) return defaultValue;
518
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
519
+ const answer = (await rl.question(`${label}${suffix}: `)).trim();
520
+ return answer || defaultValue;
521
+ }
522
+
523
+ async function askChoice(rl, label, defaultValue) {
524
+ const value = (await ask(rl, label, defaultValue)).toLowerCase();
525
+ if (["1", "yes", "y", "true", "on"].includes(value)) return "true";
526
+ if (["0", "no", "n", "false", "off"].includes(value)) return "false";
527
+ return value || defaultValue;
528
+ }
529
+
530
+ function check(name, ok, detail, status = "fail") {
531
+ return {
532
+ name,
533
+ ok,
534
+ detail,
535
+ status,
536
+ icon: ok ? "✅" : status === "warn" ? "⚠️" : "❌",
537
+ };
538
+ }
539
+
540
+ function findExecutable(command) {
541
+ if (!command) return null;
542
+ if (command.includes(path.sep) && fs.existsSync(command)) return command;
543
+ const paths = (process.env.PATH || "").split(path.delimiter);
544
+ for (const dir of paths) {
545
+ const candidate = path.join(dir, command);
546
+ if (fs.existsSync(candidate)) return candidate;
547
+ }
548
+ return null;
549
+ }
550
+
551
+ function validateStateBackend() {
552
+ const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
553
+ if (backend === "json") return true;
554
+ if (backend !== "sqlite") return false;
555
+ try {
556
+ require("better-sqlite3");
557
+ return true;
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+
369
563
  function sleep(ms) {
370
564
  return new Promise((resolve) => setTimeout(resolve, ms));
371
565
  }
@@ -375,6 +569,9 @@ async function main() {
375
569
  if (options.command === "start") return commandStart(options);
376
570
  if (options.command === "stop") return commandStop(options);
377
571
  if (options.command === "status") return commandStatus(options);
572
+ if (options.command === "init") return commandInit(options);
573
+ if (options.command === "doctor") return commandDoctor(options);
574
+ if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
378
575
  if (options.command === "restart") {
379
576
  await commandStop(options);
380
577
  return commandStart(options);
@@ -386,7 +583,7 @@ async function main() {
386
583
  }
387
584
 
388
585
  console.error(`Unknown command: ${options.command}`);
389
- console.error("Usage: nordrelay [start|stop|restart|status|foreground]");
586
+ console.error("Usage: nordrelay [init|doctor|web|start|stop|restart|status|foreground|version]");
390
587
  process.exitCode = 2;
391
588
  }
392
589