@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/.env.example +155 -64
- package/README.md +80 -58
- package/dist/access-control.js +126 -114
- package/dist/agent-feature-matrix.js +42 -0
- package/dist/agent-updates.js +312 -0
- package/dist/bot-rendering.js +838 -0
- package/dist/bot.js +130 -1371
- package/dist/channel-actions.js +372 -0
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +33 -8
- package/dist/relay-runtime.js +159 -31
- package/dist/session-format.js +72 -3
- package/dist/settings-service.js +2 -117
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +54 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-update-commands.js +88 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +56 -0
- package/dist/web-dashboard-assets.js +33 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +649 -369
- package/dist/webui-assets/dashboard.css +919 -0
- package/dist/webui-assets/dashboard.js +1611 -0
- package/package.json +6 -3
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +283 -87
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nordbyte/nordrelay",
|
|
3
|
-
"version": "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
|
+
"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`
|
|
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:
|
|
53
|
-
port:
|
|
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-
|
|
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
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
? "
|
|
320
|
-
:
|
|
321
|
-
? "
|
|
322
|
-
:
|
|
323
|
-
? "
|
|
324
|
-
:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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("
|
|
370
|
-
checks.push(check("
|
|
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
|
|
476
|
-
|
|
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:
|
|
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:
|
|
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 ??
|
|
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
|
-
|
|
646
|
-
|
|
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
|
|
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.
|