@nordbyte/nordrelay 0.2.1 → 0.3.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.
@@ -0,0 +1,131 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createDocumentStore } from "./state-backend.js";
3
+ const DEFAULT_CHAT_LIMIT = 300;
4
+ const DEFAULT_ACTIVITY_LIMIT = 1000;
5
+ export class WebChatStore {
6
+ store;
7
+ maxMessages;
8
+ constructor(workspace, backend = "json", maxMessages = DEFAULT_CHAT_LIMIT) {
9
+ this.store = createDocumentStore({
10
+ workspace,
11
+ fileName: "web-chat.json",
12
+ sqliteKey: "web-chat",
13
+ backend,
14
+ });
15
+ this.maxMessages = maxMessages;
16
+ }
17
+ append(input) {
18
+ const payload = this.readPayload();
19
+ const threadId = input.threadId || "pending";
20
+ const messages = payload.messagesByThread[threadId] ?? [];
21
+ const message = {
22
+ id: randomId(),
23
+ timestamp: input.timestamp ?? new Date().toISOString(),
24
+ ...input,
25
+ threadId,
26
+ };
27
+ messages.push(message);
28
+ if (messages.length > this.maxMessages) {
29
+ messages.splice(0, messages.length - this.maxMessages);
30
+ }
31
+ payload.messagesByThread[threadId] = messages;
32
+ this.store.write(payload);
33
+ return message;
34
+ }
35
+ list(threadId, limit = 200) {
36
+ const messages = this.readPayload().messagesByThread[threadId || "pending"] ?? [];
37
+ return messages.slice(-Math.max(1, Math.min(this.maxMessages, limit)));
38
+ }
39
+ clear(threadId) {
40
+ const payload = this.readPayload();
41
+ const key = threadId || "pending";
42
+ const count = payload.messagesByThread[key]?.length ?? 0;
43
+ delete payload.messagesByThread[key];
44
+ this.store.write(payload);
45
+ return count;
46
+ }
47
+ readPayload() {
48
+ const payload = this.store.read();
49
+ if (!payload || payload.version !== 1 || !payload.messagesByThread || typeof payload.messagesByThread !== "object") {
50
+ return { version: 1, messagesByThread: {} };
51
+ }
52
+ const messagesByThread = {};
53
+ for (const [threadId, messages] of Object.entries(payload.messagesByThread)) {
54
+ if (Array.isArray(messages)) {
55
+ messagesByThread[threadId] = messages.filter(isWebChatMessage).slice(-this.maxMessages);
56
+ }
57
+ }
58
+ return { version: 1, messagesByThread };
59
+ }
60
+ }
61
+ export class WebActivityStore {
62
+ store;
63
+ maxEvents;
64
+ constructor(workspace, backend = "json", maxEvents = DEFAULT_ACTIVITY_LIMIT) {
65
+ this.store = createDocumentStore({
66
+ workspace,
67
+ fileName: "web-activity.json",
68
+ sqliteKey: "web-activity",
69
+ backend,
70
+ });
71
+ this.maxEvents = maxEvents;
72
+ }
73
+ append(input) {
74
+ const payload = this.readPayload();
75
+ const event = {
76
+ id: randomId(),
77
+ timestamp: input.timestamp ?? new Date().toISOString(),
78
+ ...input,
79
+ };
80
+ payload.events.push(event);
81
+ if (payload.events.length > this.maxEvents) {
82
+ payload.events.splice(0, payload.events.length - this.maxEvents);
83
+ }
84
+ this.store.write(payload);
85
+ return event;
86
+ }
87
+ list(options = {}) {
88
+ const limit = Math.max(1, Math.min(500, options.limit ?? 100));
89
+ return this.readPayload().events
90
+ .filter((event) => !options.source || options.source === "all" || event.source === options.source)
91
+ .filter((event) => !options.status || options.status === "all" || event.status === options.status)
92
+ .slice(-limit)
93
+ .reverse();
94
+ }
95
+ readPayload() {
96
+ const payload = this.store.read();
97
+ if (!payload || payload.version !== 1 || !Array.isArray(payload.events)) {
98
+ return { version: 1, events: [] };
99
+ }
100
+ return {
101
+ version: 1,
102
+ events: payload.events.filter(isWebActivityEvent).slice(-this.maxEvents),
103
+ };
104
+ }
105
+ }
106
+ function isWebChatMessage(value) {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
108
+ return false;
109
+ }
110
+ const candidate = value;
111
+ return typeof candidate.id === "string" &&
112
+ typeof candidate.threadId === "string" &&
113
+ typeof candidate.text === "string" &&
114
+ typeof candidate.timestamp === "string" &&
115
+ ["user", "agent", "system", "tool"].includes(candidate.role) &&
116
+ ["web", "cli"].includes(candidate.source);
117
+ }
118
+ function isWebActivityEvent(value) {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
120
+ return false;
121
+ }
122
+ const candidate = value;
123
+ return typeof candidate.id === "string" &&
124
+ typeof candidate.timestamp === "string" &&
125
+ typeof candidate.type === "string" &&
126
+ ["web", "cli"].includes(candidate.source) &&
127
+ ["queued", "running", "completed", "failed", "aborted", "info"].includes(candidate.status);
128
+ }
129
+ function randomId() {
130
+ return randomUUID().replace(/-/g, "").slice(0, 12);
131
+ }
@@ -3,7 +3,7 @@ services:
3
3
  build: .
4
4
  env_file: .env
5
5
  volumes:
6
- - ${HOME}/.codex:/home/nordrelay/.codex:rw
6
+ - ${HOME}/.nordrelay:/home/nordrelay/.nordrelay:rw
7
7
  - ./workspace:/workspace:rw
8
8
  cap_drop:
9
9
  - ALL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
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,19 +1,22 @@
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), "..");
14
17
  const DEFAULT_MARKETPLACE_ROOT = path.resolve(PLUGIN_ROOT, "../..");
15
18
  const RUNTIME_ROOT = findRuntimeRoot();
16
- const DEFAULT_HOME = path.join(os.homedir(), ".codex", "nordrelay");
19
+ const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
17
20
 
18
21
  function nowIso() {
19
22
  return new Date().toISOString();
@@ -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");
@@ -63,16 +77,11 @@ async function mkdirp(dir) {
63
77
  }
64
78
 
65
79
  function loadEnvFiles(home) {
66
- const files = [
67
- path.join(process.cwd(), ".env"),
68
- path.join(RUNTIME_ROOT, ".env"),
69
- path.join(PLUGIN_ROOT, ".env"),
70
- path.join(home, "nordrelay.env"),
71
- ];
80
+ const envPath = process.env.NORDRELAY_ENV_FILE
81
+ ? path.resolve(process.env.NORDRELAY_ENV_FILE)
82
+ : path.join(home, "nordrelay.env");
72
83
 
73
- for (const envPath of files) {
74
- loadEnvFile(envPath);
75
- }
84
+ loadEnvFile(envPath);
76
85
 
77
86
  normalizeEnvAliases();
78
87
  }
@@ -193,7 +202,7 @@ async function commandStart(options) {
193
202
  await fsp.rm(options.pidFile, { force: true });
194
203
  }
195
204
  console.log(`Startup failed. Log: ${options.logFile}`);
196
- console.log(state.error || "Unknown error");
205
+ console.log(state.error || await readStartupError(options.logFile) || "Unknown error");
197
206
  process.exitCode = 1;
198
207
  return;
199
208
  }
@@ -254,6 +263,125 @@ async function commandStatus(options) {
254
263
  if (state.error) console.log(`Error: ${state.error}`);
255
264
  }
256
265
 
266
+ async function commandInit(options) {
267
+ await mkdirp(options.home);
268
+ const envPath = path.join(options.home, "nordrelay.env");
269
+ if (fs.existsSync(envPath) && !options.force) {
270
+ console.log(`Config already exists: ${envPath}`);
271
+ console.log("Run with --force to overwrite.");
272
+ return;
273
+ }
274
+
275
+ const rl = process.stdin.isTTY
276
+ ? readline.createInterface({ input: process.stdin, output: process.stdout })
277
+ : null;
278
+ try {
279
+ const telegramBotToken = options.telegramBotToken ||
280
+ process.env.TELEGRAM_BOT_TOKEN ||
281
+ await ask(rl, "Telegram bot token", "");
282
+ const telegramAdminUserIds = options.telegramAdminUserIds ||
283
+ process.env.TELEGRAM_ADMIN_USER_IDS ||
284
+ await ask(rl, "Telegram admin user id", "");
285
+ const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
286
+ const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
287
+ const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
288
+
289
+ if (!telegramBotToken) throw new Error("Telegram bot token is required.");
290
+ if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
291
+ if (enableCodex !== "true" && enablePi !== "true") throw new Error("At least one agent must be enabled.");
292
+
293
+ const lines = [
294
+ "# NordRelay local runtime config.",
295
+ "# Keep this file private; it contains bot credentials.",
296
+ `TELEGRAM_BOT_TOKEN=${telegramBotToken}`,
297
+ `TELEGRAM_ADMIN_USER_IDS=${telegramAdminUserIds}`,
298
+ "TELEGRAM_ALLOW_ANY_CHAT=false",
299
+ `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
300
+ `NORDRELAY_PI_ENABLED=${enablePi}`,
301
+ `NORDRELAY_DEFAULT_AGENT=${enableCodex === "true" ? "codex" : "pi"}`,
302
+ `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
303
+ "TELEGRAM_TRANSPORT=polling",
304
+ "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
305
+ "",
306
+ ];
307
+
308
+ await fsp.writeFile(envPath, lines.join("\n"), { mode: 0o600 });
309
+ await fsp.chmod(envPath, 0o600).catch(() => {});
310
+ console.log(`Wrote ${envPath}`);
311
+ console.log("Run `nordrelay doctor` to validate the setup.");
312
+ } finally {
313
+ rl?.close();
314
+ }
315
+ }
316
+
317
+ async function commandDoctor(options) {
318
+ await mkdirp(options.home);
319
+ loadEnvFiles(options.home);
320
+ const checks = [];
321
+ checks.push(check("Node.js >= 22", Number.parseInt(process.versions.node.split(".")[0], 10) >= 22, process.version));
322
+ checks.push(check("Telegram bot token", Boolean(process.env.TELEGRAM_BOT_TOKEN), process.env.TELEGRAM_BOT_TOKEN ? "configured" : "missing"));
323
+ checks.push(check("Telegram admin ids", Boolean(process.env.TELEGRAM_ADMIN_USER_IDS), process.env.TELEGRAM_ADMIN_USER_IDS ? "configured" : "missing"));
324
+ checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
325
+ checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
326
+ 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"));
327
+ 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"));
328
+ 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"));
329
+ checks.push(check("ffmpeg", Boolean(findExecutable("ffmpeg")), findExecutable("ffmpeg") || "not found", "warn"));
330
+ const stateBackendCheck = validateStateBackend();
331
+ checks.push(check("State backend", stateBackendCheck.ok, stateBackendCheck.detail));
332
+ checks.push(check("Runtime entry", Boolean(await resolveRuntimeEntry()), RUNTIME_ROOT));
333
+
334
+ for (const item of checks) {
335
+ console.log(`${item.icon} ${item.name}: ${item.detail}`);
336
+ }
337
+
338
+ const failed = checks.filter((item) => item.status === "fail" && !item.ok);
339
+ const warned = checks.filter((item) => item.status === "warn" && !item.ok);
340
+ console.log(`\nSummary: ${failed.length} failed, ${warned.length} warnings.`);
341
+ if (failed.length > 0) process.exitCode = 1;
342
+ }
343
+
344
+ async function commandWeb(options) {
345
+ await mkdirp(options.home);
346
+ loadEnvFiles(options.home);
347
+ const host = options.host || "127.0.0.1";
348
+ const port = Number.isFinite(options.port) ? options.port : 31878;
349
+ const entry = await resolveWebRuntimeEntry();
350
+ if (!entry) {
351
+ throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
352
+ }
353
+
354
+ const env = {
355
+ ...process.env,
356
+ NORDRELAY_HOME: options.home,
357
+ NORDRELAY_SOURCE_ROOT: RUNTIME_ROOT,
358
+ NORDRELAY_DASHBOARD_HOST: host,
359
+ NORDRELAY_DASHBOARD_PORT: String(port),
360
+ };
361
+ const child = spawn(entry.command, [...entry.args, "--host", host, "--port", String(port), "--home", options.home], {
362
+ cwd: RUNTIME_ROOT,
363
+ env,
364
+ stdio: "inherit",
365
+ });
366
+
367
+ const forwardSignal = (signal) => {
368
+ if (isProcessRunning(child.pid)) {
369
+ child.kill(signal);
370
+ }
371
+ };
372
+ process.once("SIGINT", () => forwardSignal("SIGINT"));
373
+ process.once("SIGTERM", () => forwardSignal("SIGTERM"));
374
+
375
+ const exit = await new Promise((resolve) => {
376
+ child.once("exit", (code, signal) => resolve({ code, signal }));
377
+ });
378
+ if (exit.signal) {
379
+ process.kill(process.pid, exit.signal);
380
+ return;
381
+ }
382
+ process.exit(exit.code ?? 0);
383
+ }
384
+
257
385
  async function commandForeground(options) {
258
386
  await mkdirp(options.home);
259
387
  loadEnvFiles(options.home);
@@ -307,12 +435,14 @@ async function commandForeground(options) {
307
435
  child.once("exit", (code, signal) => resolve({ code, signal }));
308
436
  });
309
437
 
438
+ const previousState = await readJson(options.stateFile, {});
310
439
  await writeJsonAtomic(options.stateFile, {
311
440
  status: exit.code === 0 ? "stopped" : "error",
312
441
  pid: process.pid,
313
442
  updatedAt: nowIso(),
314
443
  exitCode: exit.code,
315
444
  signal: exit.signal,
445
+ error: exit.code === 0 ? undefined : previousState.error,
316
446
  logFile: options.logFile,
317
447
  });
318
448
 
@@ -338,6 +468,21 @@ async function resolveRuntimeEntry() {
338
468
  return null;
339
469
  }
340
470
 
471
+ async function resolveWebRuntimeEntry() {
472
+ const distEntry = path.join(RUNTIME_ROOT, "dist", "web-dashboard.js");
473
+ if (fs.existsSync(distEntry)) {
474
+ return { command: process.execPath, args: [distEntry] };
475
+ }
476
+
477
+ const tsEntry = path.join(RUNTIME_ROOT, "src", "web-dashboard.ts");
478
+ const tsxBin = path.join(RUNTIME_ROOT, "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx");
479
+ if (fs.existsSync(tsEntry) && fs.existsSync(tsxBin)) {
480
+ return { command: tsxBin, args: [tsEntry] };
481
+ }
482
+
483
+ return null;
484
+ }
485
+
341
486
  function findRuntimeRoot() {
342
487
  const candidates = [
343
488
  process.env.NORDRELAY_SOURCE_ROOT,
@@ -366,6 +511,80 @@ function findRuntimeRoot() {
366
511
  return DEFAULT_MARKETPLACE_ROOT;
367
512
  }
368
513
 
514
+ async function ask(rl, label, defaultValue) {
515
+ if (!rl) return defaultValue;
516
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
517
+ const answer = (await rl.question(`${label}${suffix}: `)).trim();
518
+ return answer || defaultValue;
519
+ }
520
+
521
+ async function askChoice(rl, label, defaultValue) {
522
+ const value = (await ask(rl, label, defaultValue)).toLowerCase();
523
+ if (["1", "yes", "y", "true", "on"].includes(value)) return "true";
524
+ if (["0", "no", "n", "false", "off"].includes(value)) return "false";
525
+ return value || defaultValue;
526
+ }
527
+
528
+ function check(name, ok, detail, status = "fail") {
529
+ return {
530
+ name,
531
+ ok,
532
+ detail,
533
+ status,
534
+ icon: ok ? "✅" : status === "warn" ? "⚠️" : "❌",
535
+ };
536
+ }
537
+
538
+ function findExecutable(command) {
539
+ if (!command) return null;
540
+ if (command.includes(path.sep) && fs.existsSync(command)) return command;
541
+ const paths = (process.env.PATH || "").split(path.delimiter);
542
+ for (const dir of paths) {
543
+ const candidate = path.join(dir, command);
544
+ if (fs.existsSync(candidate)) return candidate;
545
+ }
546
+ return null;
547
+ }
548
+
549
+ function validateStateBackend() {
550
+ const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
551
+ if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
552
+ if (backend !== "sqlite") return { ok: false, detail: `Invalid NORDRELAY_STATE_BACKEND=${backend}` };
553
+ try {
554
+ const Database = require("better-sqlite3");
555
+ const filePath = path.join(process.cwd(), ".nordrelay", "state.sqlite");
556
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
557
+ const db = new Database(filePath);
558
+ db.exec([
559
+ "CREATE TABLE IF NOT EXISTS documents (",
560
+ "key TEXT PRIMARY KEY,",
561
+ "json TEXT NOT NULL,",
562
+ "updated_at TEXT NOT NULL",
563
+ ")",
564
+ ].join(" "));
565
+ db.close?.();
566
+ return { ok: true, detail: `NORDRELAY_STATE_BACKEND=sqlite (${filePath})` };
567
+ } catch (error) {
568
+ return {
569
+ ok: false,
570
+ detail: `NORDRELAY_STATE_BACKEND=sqlite failed: ${error instanceof Error ? error.message : String(error)}`,
571
+ };
572
+ }
573
+ }
574
+
575
+ async function readStartupError(logFile) {
576
+ try {
577
+ const lines = (await fsp.readFile(logFile, "utf8")).split(/\r?\n/).filter(Boolean).slice(-80).reverse();
578
+ const startupLine = lines.find((line) => line.includes("Failed to start NordRelay:"));
579
+ if (startupLine) return startupLine.replace(/^.*Failed to start NordRelay:\s*/, "");
580
+ const errorLine = lines.find((line) => /\bERROR\b/i.test(line));
581
+ if (errorLine) return errorLine;
582
+ return lines[0] || null;
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+
369
588
  function sleep(ms) {
370
589
  return new Promise((resolve) => setTimeout(resolve, ms));
371
590
  }
@@ -375,6 +594,9 @@ async function main() {
375
594
  if (options.command === "start") return commandStart(options);
376
595
  if (options.command === "stop") return commandStop(options);
377
596
  if (options.command === "status") return commandStatus(options);
597
+ if (options.command === "init") return commandInit(options);
598
+ if (options.command === "doctor") return commandDoctor(options);
599
+ if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
378
600
  if (options.command === "restart") {
379
601
  await commandStop(options);
380
602
  return commandStart(options);
@@ -386,7 +608,7 @@ async function main() {
386
608
  }
387
609
 
388
610
  console.error(`Unknown command: ${options.command}`);
389
- console.error("Usage: nordrelay [start|stop|restart|status|foreground]");
611
+ console.error("Usage: nordrelay [init|doctor|web|start|stop|restart|status|foreground|version]");
390
612
  process.exitCode = 2;
391
613
  }
392
614