@nordbyte/nordrelay 0.5.1 → 0.5.2

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 CHANGED
@@ -209,6 +209,8 @@ NORDRELAY_AUDIT_MAX_EVENTS=1000
209
209
  NORDRELAY_SESSION_LOCK_TTL_MS=1800000
210
210
  # NPM version cache TTL.
211
211
  NORDRELAY_VERSION_CACHE_TTL_MS=3600000
212
+ # Installed agent CLI version cache TTL.
213
+ NORDRELAY_CLI_VERSION_CACHE_TTL_MS=60000
212
214
 
213
215
  # Voice
214
216
  # Optional voice transcription settings.
package/README.md CHANGED
@@ -183,9 +183,9 @@ Authentication and safety:
183
183
  Operations:
184
184
 
185
185
  - Plugin command/skill starts, stops, restarts, and inspects the connector process.
186
- - Manual process commands support `start`, `stop`, `restart`, `status`, and `foreground`.
186
+ - Manual process commands support `start`, `stop`, `restart`, `status`, `update`, and `foreground`.
187
187
  - Telegram admin commands support `/logs`, `/diagnostics`, `/support`, `/restart`, and `/update` for NordRelay and agent CLIs.
188
- - `/update` detects the install type: npm installs update with `npm install -g @nordbyte/nordrelay@latest`; source checkouts pull `origin/main`, install dependencies, run check, tests, and build, then restart.
188
+ - `nordrelay update`, `/update`, and the WebUI update button detect the install type: npm installs update with `npm install -g @nordbyte/nordrelay@latest`; source checkouts pull `origin/main`, install dependencies, run check, tests, and build, then restart if the connector is running.
189
189
  - `/update agents`, `/update <agent>`, `/update install <agent>`, `/update jobs`, `/update log <id>`, `/update cancel <id>`, and `/update input <id> <text>` manage Codex, Pi, Hermes, OpenClaw, and Claude Code updater or installer jobs from Telegram.
190
190
  - `/logs` renders redacted connector, NordRelay update, and agent update logs with local-time timestamps, levels, file path, last-modified time, and highlighted warnings/errors.
191
191
  - Logs can be emitted as timestamped plain text or JSON records with `CONNECTOR_LOG_FORMAT`.
@@ -199,7 +199,7 @@ Operations:
199
199
  - The WebUI exposes REST and SSE endpoints for chat streaming, sessions, settings, queue, artifacts, logs, health, diagnostics, and redacted diagnostics bundle export.
200
200
  - The dashboard can bind to `127.0.0.1` or `0.0.0.0`; user login and session cookies are mandatory in both modes.
201
201
  - Telegram can run with long polling or an HTTP webhook via `TELEGRAM_TRANSPORT=webhook`.
202
- - Version freshness checks are cached with `NORDRELAY_VERSION_CACHE_TTL_MS` to keep `/version` responsive.
202
+ - Version freshness checks are cached with `NORDRELAY_VERSION_CACHE_TTL_MS`, and installed agent CLI version checks are cached with `NORDRELAY_CLI_VERSION_CACHE_TTL_MS`, to keep `/version` and adapter health responsive.
203
203
  - CI runs on Ubuntu, Windows, and macOS with typecheck, Vitest, Playwright WebUI browser tests, package dry run, npm audit, and a separate secret-scan workflow.
204
204
  - `npm run dev`, `npm run build`, `npm run check`, `npm test`, `npm run test:e2e`, `npm start`, `npm stop`, and `npm run status` are available.
205
205
  - Dockerfile and `docker-compose.yml` are included for containerized operation.
@@ -356,6 +356,7 @@ nordrelay init
356
356
  nordrelay doctor
357
357
  nordrelay start
358
358
  nordrelay status
359
+ nordrelay update
359
360
  nordrelay restart
360
361
  nordrelay stop
361
362
  nordrelay foreground
@@ -367,6 +368,7 @@ Source checkout process commands:
367
368
  ```bash
368
369
  node plugins/nordrelay/scripts/nordrelay.mjs start
369
370
  node plugins/nordrelay/scripts/nordrelay.mjs status
371
+ node plugins/nordrelay/scripts/nordrelay.mjs update
370
372
  node plugins/nordrelay/scripts/nordrelay.mjs restart
371
373
  node plugins/nordrelay/scripts/nordrelay.mjs stop
372
374
  node plugins/nordrelay/scripts/nordrelay.mjs foreground
@@ -737,6 +739,7 @@ Agent selection:
737
739
  - `NORDRELAY_AUDIT_MAX_EVENTS`: maximum audit events retained. Defaults to `1000`.
738
740
  - `NORDRELAY_SESSION_LOCK_TTL_MS`: session write-lock TTL. Defaults to `1800000`.
739
741
  - `NORDRELAY_VERSION_CACHE_TTL_MS`: npm version freshness cache TTL. Defaults to `3600000`; set `0` to disable.
742
+ - `NORDRELAY_CLI_VERSION_CACHE_TTL_MS`: installed agent CLI version cache TTL. Defaults to `60000`; set `0` to disable.
740
743
 
741
744
  Dashboard:
742
745
 
@@ -837,7 +840,7 @@ NordRelay wrapper:
837
840
 
838
841
  - `NORDRELAY_HOME`: config/state/log directory override. Defaults to `~/.nordrelay`.
839
842
  - `NORDRELAY_SOURCE_ROOT`: runtime source root override. Useful when the plugin is launched from Codex cache.
840
- - `NORDRELAY_UPDATE_METHOD`: optional `auto`, `npm`, or `git` self-update method override. Auto uses git when the runtime root has a `.git` directory and npm otherwise.
843
+ - `NORDRELAY_UPDATE_METHOD`: optional `auto`, `npm`, or `git` self-update method override used by `nordrelay update`, `/update`, and the WebUI update button. Auto uses git when the runtime root has a `.git` directory and npm otherwise.
841
844
  - Agent updates from the dashboard and Telegram use each agent's native updater where possible: `codex update`, `pi update pi`, `hermes update --yes`, `openclaw update --yes`, and `claude update`. Not-installed agents can be installed from the dashboard or with `/update install <agent>` using npm global installs.
842
845
  - `NORDRELAY_KEEP_PENDING_UPDATES`: set true to avoid dropping pending Telegram updates on start.
843
846
  - `NORDRELAY_FORWARD_TOOL_OUTPUT`: backward-compatible alias that sets `TOOL_VERBOSITY=all` when `TOOL_VERBOSITY` is unset.
@@ -105,10 +105,11 @@ export class AgentUpdateManager {
105
105
  `Working directory: ${job.cwd}`,
106
106
  "",
107
107
  ].join("\n"));
108
- const child = spawn(plan.command, plan.args, {
108
+ const useShell = process.platform === "win32";
109
+ const child = spawn(useShell ? formatShellCommand(plan.command, plan.args) : plan.command, useShell ? [] : plan.args, {
109
110
  cwd: plan.cwd,
110
111
  env: { ...process.env, ...(this.options.env ?? {}), ...(context.env ?? {}) },
111
- shell: process.platform === "win32",
112
+ shell: useShell,
112
113
  windowsHide: true,
113
114
  stdio: "pipe",
114
115
  });
@@ -342,6 +343,21 @@ function looksLikePrompt(text) {
342
343
  const tail = text.split(/\r?\n/).slice(-4).join("\n");
343
344
  return /\b(y\/n|yes\/no|continue|proceed|confirm|password|passphrase|token|api key|enter|select)\b|[?>]\s*$/i.test(tail);
344
345
  }
346
+ function formatShellCommand(command, args) {
347
+ return [command, ...args].map(quoteWindowsCmdArg).join(" ");
348
+ }
349
+ function quoteWindowsCmdArg(value) {
350
+ if (value.length === 0) {
351
+ return "\"\"";
352
+ }
353
+ if (!/[\s"&|<>()^%]/.test(value)) {
354
+ return value;
355
+ }
356
+ return `"${value
357
+ .replace(/%/g, "%%")
358
+ .replace(/(\\*)"/g, '$1$1\\"')
359
+ .replace(/(\\+)$/g, "$1$1")}"`;
360
+ }
345
361
  function capitalize(value) {
346
362
  return value.charAt(0).toUpperCase() + value.slice(1);
347
363
  }
package/dist/bot.js CHANGED
@@ -453,6 +453,30 @@ export function createBot(config, registry) {
453
453
  await drainQueuedPrompts(createSystemContext(contextKey), contextKey, parsed.chatId, session);
454
454
  }
455
455
  };
456
+ const sendExternalMirrorTyping = async (chatId, messageThreadId, state) => {
457
+ const now = Date.now();
458
+ if (state.lastTypingAt && now - state.lastTypingAt < TYPING_INTERVAL_MS) {
459
+ return;
460
+ }
461
+ state.lastTypingAt = now;
462
+ await sendChatActionSafe(bot.api, chatId, "typing", messageThreadId).catch(() => { });
463
+ };
464
+ const sendExternalWorkingNotice = async (chatId, messageThreadId, state, snapshot) => {
465
+ const turnKey = snapshot.activity.turnId ?? snapshot.activity.startedAt?.toISOString() ?? "unknown";
466
+ if (state.workingNoticeTurnKey === turnKey) {
467
+ return;
468
+ }
469
+ const prompt = trimLine(snapshot.latestUserMessage ?? "", 250);
470
+ const fallbackText = prompt ? `Working on ${prompt}` : `Working on external ${snapshot.agentLabel} task...`;
471
+ const html = prompt
472
+ ? `<b>Working on</b> ${escapeHTML(prompt)}`
473
+ : `<b>Working on</b> external ${escapeHTML(snapshot.agentLabel)} task...`;
474
+ await sendTextMessage(bot.api, chatId, html, {
475
+ fallbackText,
476
+ messageThreadId,
477
+ });
478
+ state.workingNoticeTurnKey = turnKey;
479
+ };
456
480
  const mirrorExternalSnapshot = async (contextKey, chatId, session, snapshot) => {
457
481
  const parsed = parseContextKey(contextKey);
458
482
  const previous = externalMirrors.get(contextKey);
@@ -471,7 +495,15 @@ export function createBot(config, registry) {
471
495
  if (snapshot.activity.active) {
472
496
  state.turnId = snapshot.activity.turnId;
473
497
  state.startedAt = snapshot.activity.startedAt;
474
- if (mirrorMode === "off" || mirrorMode === "final") {
498
+ if (mirrorMode !== "off") {
499
+ await sendExternalMirrorTyping(chatId, parsed.messageThreadId, state);
500
+ }
501
+ if (mirrorMode === "final") {
502
+ await sendExternalWorkingNotice(chatId, parsed.messageThreadId, state, snapshot);
503
+ state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
504
+ return;
505
+ }
506
+ if (mirrorMode === "off") {
475
507
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
476
508
  return;
477
509
  }
@@ -510,7 +542,6 @@ export function createBot(config, registry) {
510
542
  state.latestMirroredEventLine = event.lineNumber;
511
543
  }
512
544
  }
513
- await sendChatActionSafe(bot.api, chatId, "typing", parsed.messageThreadId).catch(() => { });
514
545
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
515
546
  return;
516
547
  }
@@ -551,6 +582,7 @@ export function createBot(config, registry) {
551
582
  }
552
583
  await deliverCliGeneratedArtifacts(contextKey, chatId, session, state.startedAt, terminalEvent.turnId, parsed.messageThreadId);
553
584
  }
585
+ state.workingNoticeTurnKey = undefined;
554
586
  state.lastLine = Math.max(state.lastLine, snapshot.lineCount);
555
587
  };
556
588
  const canSendSystemMessagesToContext = (contextKey) => {
package/dist/codex-cli.js CHANGED
@@ -26,7 +26,7 @@ export function findExecutableOnPath(command, pathValue) {
26
26
  return undefined;
27
27
  }
28
28
  const extensions = process.platform === "win32"
29
- ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
29
+ ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")]
30
30
  : [""];
31
31
  for (const rawDirectory of pathValue.split(path.delimiter)) {
32
32
  const directory = rawDirectory.trim();
@@ -88,6 +88,7 @@ export const SETTING_DEFINITIONS = [
88
88
  setting("NORDRELAY_AUDIT_MAX_EVENTS", "Audit max events", "Workspace", "number", "Retained audit events.", true),
89
89
  setting("NORDRELAY_SESSION_LOCK_TTL_MS", "Session lock TTL", "Workspace", "number", "Write-lock TTL.", true),
90
90
  setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
91
+ setting("NORDRELAY_CLI_VERSION_CACHE_TTL_MS", "CLI version cache TTL", "Workspace", "number", "Installed agent CLI version cache TTL.", true),
91
92
  setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
92
93
  setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false, ["auto", "parakeet", "faster-whisper", "openai"]),
93
94
  setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
@@ -180,6 +181,7 @@ const EXAMPLE_VALUES = {
180
181
  "NORDRELAY_AUDIT_MAX_EVENTS": "1000",
181
182
  "NORDRELAY_SESSION_LOCK_TTL_MS": "1800000",
182
183
  "NORDRELAY_VERSION_CACHE_TTL_MS": "3600000",
184
+ "NORDRELAY_CLI_VERSION_CACHE_TTL_MS": "60000",
183
185
  "NORDRELAY_DASHBOARD_HOST": "127.0.0.1",
184
186
  "NORDRELAY_DASHBOARD_PORT": "31878",
185
187
  "NORDRELAY_ENV_FILE": "",
@@ -1,5 +1,5 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
@@ -21,6 +21,8 @@ const CLAUDE_CODE_SDK_PACKAGE_NAME = "@anthropic-ai/claude-agent-sdk";
21
21
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
22
22
  const SECRET_RE = /(bot|token|api[_-]?key|authorization|bearer|password|secret)(["'=: ]+)([^\s"',]+)/gi;
23
23
  const DEFAULT_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
24
+ const DEFAULT_CLI_VERSION_CACHE_TTL_MS = 60 * 1000;
25
+ const cliVersionCache = new Map();
24
26
  export function getConnectorHome() {
25
27
  return process.env.NORDRELAY_HOME || DEFAULT_HOME;
26
28
  }
@@ -96,97 +98,86 @@ export async function getPackageVersion() {
96
98
  }
97
99
  }
98
100
  export async function getVersionChecks(options = {}) {
99
- const nordrelayVersion = await getPackageVersion();
100
- const codexCli = resolveCodexCli();
101
- const piCli = resolvePiCli(process.env, options.piCliPath);
102
- const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
103
- const openClawCli = resolveOpenClawCli(process.env, options.openClawCliPath);
104
- const claudeCodeCli = resolveClaudeCodeCli(process.env, options.claudeCodeCliPath);
105
- const codexVersionLabel = codexCli.path
106
- ? detectCliVersion(codexCli.path)
107
- : readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "not installed";
108
- const piVersionLabel = piCli.path
109
- ? detectCliVersion(piCli.path)
110
- : readInstalledPackageVersion(PI_PACKAGE_NAME) ?? readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME) ?? "not installed";
111
- const legacyPiPackageVersion = readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME);
112
- const hermesVersionLabel = hermesCli.path ? detectCliVersion(hermesCli.path) : "not installed";
113
- const openClawVersionLabel = openClawCli.path ? detectCliVersion(openClawCli.path) : "not installed";
114
- const claudeCodeVersionLabel = claudeCodeCli.path
115
- ? detectCliVersion(claudeCodeCli.path)
116
- : readInstalledPackageVersion(CLAUDE_CODE_SDK_PACKAGE_NAME) ?? "bundled";
117
- const claudeCodePackageName = claudeCodeCli.path ? CLAUDE_CODE_PACKAGE_NAME : CLAUDE_CODE_SDK_PACKAGE_NAME;
118
- return {
119
- nordrelay: buildVersionCheck({
101
+ const [nordrelayVersion, cliVersions] = await Promise.all([
102
+ getPackageVersion(),
103
+ resolveAgentCliVersions(options),
104
+ ]);
105
+ const [nordrelay, codex, pi, hermes, openclaw, claudeCode,] = await Promise.all([
106
+ buildVersionCheck({
120
107
  label: "NordRelay",
121
108
  packageName: PACKAGE_NAME,
122
109
  installedLabel: nordrelayVersion,
123
110
  installedVersion: extractVersion(nordrelayVersion),
124
111
  }),
125
- codex: buildVersionCheck({
112
+ buildVersionCheck({
126
113
  label: "Codex",
127
114
  packageName: CODEX_PACKAGE_NAME,
128
- installedLabel: codexVersionLabel,
129
- installedVersion: extractVersion(codexVersionLabel),
130
- notInstalled: codexVersionLabel === "not installed",
115
+ installedLabel: cliVersions.codexVersionLabel,
116
+ installedVersion: extractVersion(cliVersions.codexVersionLabel),
117
+ notInstalled: cliVersions.codexVersionLabel === "not installed",
131
118
  }),
132
- pi: buildVersionCheck({
119
+ buildVersionCheck({
133
120
  label: "Pi",
134
121
  packageName: PI_PACKAGE_NAME,
135
- installedLabel: piVersionLabel,
136
- installedVersion: extractVersion(piVersionLabel),
137
- notInstalled: piVersionLabel === "not installed",
138
- detail: legacyPiPackageVersion ? `Legacy package ${LEGACY_PI_PACKAGE_NAME} is present; current package is ${PI_PACKAGE_NAME}.` : undefined,
122
+ installedLabel: cliVersions.piVersionLabel,
123
+ installedVersion: extractVersion(cliVersions.piVersionLabel),
124
+ notInstalled: cliVersions.piVersionLabel === "not installed",
125
+ detail: cliVersions.legacyPiPackageVersion ? `Legacy package ${LEGACY_PI_PACKAGE_NAME} is present; current package is ${PI_PACKAGE_NAME}.` : undefined,
139
126
  }),
140
- hermes: buildHermesVersionCheck(hermesVersionLabel),
141
- openclaw: buildVersionCheck({
127
+ buildHermesVersionCheck(cliVersions.hermesVersionLabel),
128
+ buildVersionCheck({
142
129
  label: "OpenClaw",
143
130
  packageName: OPENCLAW_PACKAGE_NAME,
144
- installedLabel: openClawVersionLabel,
145
- installedVersion: extractVersion(openClawVersionLabel),
146
- notInstalled: openClawVersionLabel === "not installed",
131
+ installedLabel: cliVersions.openClawVersionLabel,
132
+ installedVersion: extractVersion(cliVersions.openClawVersionLabel),
133
+ notInstalled: cliVersions.openClawVersionLabel === "not installed",
147
134
  }),
148
- claudeCode: buildVersionCheck({
135
+ buildVersionCheck({
149
136
  label: "Claude Code",
150
- packageName: claudeCodePackageName,
151
- installedLabel: claudeCodeVersionLabel,
152
- installedVersion: extractVersion(claudeCodeVersionLabel),
153
- notInstalled: claudeCodeVersionLabel === "not installed",
137
+ packageName: cliVersions.claudeCodePackageName,
138
+ installedLabel: cliVersions.claudeCodeVersionLabel,
139
+ installedVersion: extractVersion(cliVersions.claudeCodeVersionLabel),
140
+ notInstalled: cliVersions.claudeCodeVersionLabel === "not installed",
154
141
  }),
142
+ ]);
143
+ return {
144
+ nordrelay,
145
+ codex,
146
+ pi,
147
+ hermes,
148
+ openclaw,
149
+ claudeCode,
155
150
  };
156
151
  }
157
152
  export async function getConnectorHealth(options = {}) {
158
- const rawState = await readConnectorState();
159
- const version = await getPackageVersion();
153
+ const [rawState, version, cliVersions] = await Promise.all([
154
+ readConnectorState(),
155
+ getPackageVersion(),
156
+ resolveAgentCliVersions(options),
157
+ ]);
160
158
  const pidRunning = isProcessRunning(rawState.pid);
161
159
  const appPidRunning = isProcessRunning(rawState.appPid);
162
160
  const state = normalizeConnectorState(rawState, pidRunning, appPidRunning);
163
- const codexCli = resolveCodexCli();
164
- const piCli = resolvePiCli(process.env, options.piCliPath);
165
- const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
166
- const openClawCli = resolveOpenClawCli(process.env, options.openClawCliPath);
167
- const claudeCodeCli = resolveClaudeCodeCli(process.env, options.claudeCodeCliPath);
168
161
  return {
169
162
  version,
170
163
  state,
171
164
  pidRunning,
172
165
  appPidRunning,
173
- codexCli: describeCodexCli(codexCli),
174
- codexCliPath: codexCli.path ?? null,
175
- codexCliVersion: detectCliVersion(codexCli.path),
176
- piCli: describePiCli(piCli),
177
- piCliPath: piCli.path ?? null,
178
- piCliVersion: detectCliVersion(piCli.path),
179
- hermesCli: describeHermesCli(hermesCli),
180
- hermesCliPath: hermesCli.path ?? null,
181
- hermesCliVersion: detectCliVersion(hermesCli.path),
182
- openClawCli: describeOpenClawCli(openClawCli),
183
- openClawCliPath: openClawCli.path ?? null,
184
- openClawCliVersion: detectCliVersion(openClawCli.path),
185
- claudeCodeCli: describeClaudeCodeCli(claudeCodeCli),
186
- claudeCodeCliPath: claudeCodeCli.path ?? null,
187
- claudeCodeCliVersion: claudeCodeCli.path
188
- ? detectCliVersion(claudeCodeCli.path)
189
- : readInstalledPackageVersion(CLAUDE_CODE_SDK_PACKAGE_NAME) ?? "bundled",
166
+ codexCli: describeCodexCli(cliVersions.codexCli),
167
+ codexCliPath: cliVersions.codexCli.path ?? null,
168
+ codexCliVersion: cliVersions.codexVersionLabel,
169
+ piCli: describePiCli(cliVersions.piCli),
170
+ piCliPath: cliVersions.piCli.path ?? null,
171
+ piCliVersion: cliVersions.piVersionLabel,
172
+ hermesCli: describeHermesCli(cliVersions.hermesCli),
173
+ hermesCliPath: cliVersions.hermesCli.path ?? null,
174
+ hermesCliVersion: cliVersions.hermesVersionLabel,
175
+ openClawCli: describeOpenClawCli(cliVersions.openClawCli),
176
+ openClawCliPath: cliVersions.openClawCli.path ?? null,
177
+ openClawCliVersion: cliVersions.openClawVersionLabel,
178
+ claudeCodeCli: describeClaudeCodeCli(cliVersions.claudeCodeCli),
179
+ claudeCodeCliPath: cliVersions.claudeCodeCli.path ?? null,
180
+ claudeCodeCliVersion: cliVersions.claudeCodeVersionLabel,
190
181
  stateFile: getConnectorStatePath(),
191
182
  logFile: getConnectorLogPath(),
192
183
  databasePath: findLatestDatabase(),
@@ -208,23 +199,22 @@ export function spawnSelfUpdate() {
208
199
  const script = getWrapperScriptPath();
209
200
  const updateLog = getUpdateLogPath();
210
201
  const method = detectSelfUpdateMethod(sourceRoot);
211
- const commands = method === "npm"
212
- ? buildNpmSelfUpdateCommands()
213
- : buildGitSelfUpdateCommands(script);
214
- const logFd = openSync(updateLog, "a");
215
- const command = [
216
- "set -e",
217
- `printf '\\n[%s] Starting ${method} connector self-update\\n' "$(date -Is)"`,
218
- ...commands,
219
- ].join(" && ");
220
- const child = spawn("sh", ["-lc", command], {
202
+ mkdirSync(path.dirname(updateLog), { recursive: true });
203
+ const child = spawn(process.execPath, [
204
+ script,
205
+ "update",
206
+ "--method",
207
+ method,
208
+ "--home",
209
+ getConnectorHome(),
210
+ "--keep-pending-updates",
211
+ ], {
221
212
  cwd: sourceRoot,
222
213
  detached: true,
223
214
  env: process.env,
224
- stdio: ["ignore", logFd, logFd],
215
+ stdio: "ignore",
225
216
  });
226
217
  child.unref();
227
- closeSync(logFd);
228
218
  return {
229
219
  logPath: updateLog,
230
220
  method,
@@ -274,41 +264,62 @@ function normalizeConnectorState(state, pidRunning, appPidRunning) {
274
264
  function redactSecrets(text) {
275
265
  return text.replace(SECRET_RE, "$1$2[redacted]");
276
266
  }
277
- function buildGitSelfUpdateCommands(script) {
278
- return [
279
- "git pull --ff-only origin main",
280
- "npm install",
281
- "npm run check",
282
- "npm test",
283
- "npm run build",
284
- `printf '[%s] Checks passed; restarting connector\\n' "$(date -Is)"`,
285
- `${shellQuote(process.execPath)} ${shellQuote(script)} restart --keep-pending-updates`,
286
- ];
287
- }
288
- function buildNpmSelfUpdateCommands() {
289
- return [
290
- `${resolveNpmCommand()} install -g ${PACKAGE_NAME}@latest`,
291
- "nordrelay version",
292
- `printf '[%s] npm update finished; restarting connector\\n' "$(date -Is)"`,
293
- "nordrelay restart --keep-pending-updates",
294
- ];
295
- }
296
- function resolveNpmCommand() {
297
- const npm = resolveNpmSpawnCommand();
298
- if (npm) {
299
- return [npm.command, ...npm.argsPrefix].map(shellQuote).join(" ");
300
- }
301
- return "npm";
267
+ async function resolveAgentCliVersions(options = {}) {
268
+ const codexCli = resolveCodexCli();
269
+ const piCli = resolvePiCli(process.env, options.piCliPath);
270
+ const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
271
+ const openClawCli = resolveOpenClawCli(process.env, options.openClawCliPath);
272
+ const claudeCodeCli = resolveClaudeCodeCli(process.env, options.claudeCodeCliPath);
273
+ const legacyPiPackageVersion = readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME);
274
+ const [codexVersionLabel, piVersionLabel, hermesVersionLabel, openClawVersionLabel, claudeCodeVersionLabel,] = await Promise.all([
275
+ codexCli.path ? detectCliVersion(codexCli.path) : Promise.resolve(readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "not installed"),
276
+ piCli.path ? detectCliVersion(piCli.path) : Promise.resolve(readInstalledPackageVersion(PI_PACKAGE_NAME) ?? legacyPiPackageVersion ?? "not installed"),
277
+ hermesCli.path ? detectCliVersion(hermesCli.path) : Promise.resolve("not installed"),
278
+ openClawCli.path ? detectCliVersion(openClawCli.path) : Promise.resolve("not installed"),
279
+ claudeCodeCli.path ? detectCliVersion(claudeCodeCli.path) : Promise.resolve(readInstalledPackageVersion(CLAUDE_CODE_SDK_PACKAGE_NAME) ?? "bundled"),
280
+ ]);
281
+ return {
282
+ codexCli,
283
+ piCli,
284
+ hermesCli,
285
+ openClawCli,
286
+ claudeCodeCli,
287
+ codexVersionLabel,
288
+ piVersionLabel,
289
+ hermesVersionLabel,
290
+ openClawVersionLabel,
291
+ claudeCodeVersionLabel,
292
+ claudeCodePackageName: claudeCodeCli.path ? CLAUDE_CODE_PACKAGE_NAME : CLAUDE_CODE_SDK_PACKAGE_NAME,
293
+ legacyPiPackageVersion,
294
+ };
302
295
  }
303
- function detectCliVersion(commandPath) {
296
+ async function detectCliVersion(commandPath) {
304
297
  if (!commandPath) {
305
298
  return "not installed";
306
299
  }
307
- const result = spawnSync(commandPath, ["--version"], {
308
- encoding: "utf8",
300
+ const ttlMs = parseCliVersionCacheTtlMs();
301
+ if (ttlMs > 0) {
302
+ const cached = cliVersionCache.get(commandPath);
303
+ if (cached && Date.now() < cached.expiresAt) {
304
+ if (cached.value !== undefined) {
305
+ return cached.value;
306
+ }
307
+ if (cached.promise) {
308
+ return cached.promise;
309
+ }
310
+ }
311
+ const promise = detectCliVersionUncached(commandPath);
312
+ cliVersionCache.set(commandPath, { promise, expiresAt: Date.now() + ttlMs });
313
+ const value = await promise;
314
+ cliVersionCache.set(commandPath, { value, expiresAt: Date.now() + ttlMs });
315
+ return value;
316
+ }
317
+ return detectCliVersionUncached(commandPath);
318
+ }
319
+ async function detectCliVersionUncached(commandPath) {
320
+ const result = await runCommand(commandPath, ["--version"], {
309
321
  shell: isWindowsShellScript(commandPath),
310
322
  timeout: 3000,
311
- windowsHide: true,
312
323
  });
313
324
  const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
314
325
  if (result.error) {
@@ -319,9 +330,9 @@ function detectCliVersion(commandPath) {
319
330
  }
320
331
  return output || "unknown";
321
332
  }
322
- function buildHermesVersionCheck(installedLabel) {
333
+ async function buildHermesVersionCheck(installedLabel) {
323
334
  if (installedLabel === "not installed") {
324
- const latest = detectLatestNpmVersion(HERMES_PACKAGE_NAME);
335
+ const latest = await detectLatestNpmVersion(HERMES_PACKAGE_NAME);
325
336
  return {
326
337
  label: "Hermes",
327
338
  packageName: HERMES_PACKAGE_NAME,
@@ -346,9 +357,9 @@ function buildHermesVersionCheck(installedLabel) {
346
357
  detail: updateLine ?? (installedVersion ? undefined : "Could not parse Hermes version or update status"),
347
358
  };
348
359
  }
349
- function buildVersionCheck(options) {
360
+ async function buildVersionCheck(options) {
350
361
  if (options.notInstalled) {
351
- const latest = options.skipLatest ? { version: null, error: undefined } : detectLatestNpmVersion(options.packageName);
362
+ const latest = options.skipLatest ? { version: null, error: undefined } : await detectLatestNpmVersion(options.packageName);
352
363
  return {
353
364
  label: options.label,
354
365
  packageName: options.packageName,
@@ -370,7 +381,7 @@ function buildVersionCheck(options) {
370
381
  detail: options.detail ?? "Latest-version lookup is not available for this package source",
371
382
  };
372
383
  }
373
- const latest = detectLatestNpmVersion(options.packageName);
384
+ const latest = await detectLatestNpmVersion(options.packageName);
374
385
  if (!options.installedVersion || !latest.version) {
375
386
  return {
376
387
  label: options.label,
@@ -392,7 +403,7 @@ function buildVersionCheck(options) {
392
403
  detail: [options.detail, latest.error].filter(Boolean).join(" ") || undefined,
393
404
  };
394
405
  }
395
- function detectLatestNpmVersion(packageName) {
406
+ async function detectLatestNpmVersion(packageName) {
396
407
  const cached = readVersionCache(packageName);
397
408
  if (cached) {
398
409
  return cached;
@@ -401,11 +412,9 @@ function detectLatestNpmVersion(packageName) {
401
412
  if (!npm) {
402
413
  return { version: null, error: "npm was not found on PATH; latest-version lookup is unavailable" };
403
414
  }
404
- const result = spawnSync(npm.command, [...npm.argsPrefix, "view", packageName, "version", "--registry=https://registry.npmjs.org"], {
405
- encoding: "utf8",
415
+ const result = await runCommand(npm.command, [...npm.argsPrefix, "view", packageName, "version", "--registry=https://registry.npmjs.org"], {
406
416
  shell: npm.shell,
407
417
  timeout: 5000,
408
- windowsHide: true,
409
418
  });
410
419
  const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
411
420
  if (result.error) {
@@ -418,6 +427,49 @@ function detectLatestNpmVersion(packageName) {
418
427
  writeVersionCache(packageName, resolved.version);
419
428
  return resolved;
420
429
  }
430
+ function runCommand(command, args, options) {
431
+ return new Promise((resolve) => {
432
+ const useShell = Boolean(options.shell);
433
+ execFile(useShell ? formatShellCommand(command, args) : command, useShell ? [] : args, {
434
+ encoding: "utf8",
435
+ shell: useShell,
436
+ timeout: options.timeout,
437
+ windowsHide: true,
438
+ env: process.env,
439
+ maxBuffer: 1024 * 1024,
440
+ }, (error, stdout, stderr) => {
441
+ const enriched = error;
442
+ resolve({
443
+ stdout: typeof stdout === "string" ? stdout : "",
444
+ stderr: typeof stderr === "string" ? stderr : "",
445
+ status: typeof enriched?.code === "number" ? enriched.code : error ? 1 : 0,
446
+ signal: enriched?.signal,
447
+ error: enriched ?? undefined,
448
+ });
449
+ });
450
+ });
451
+ }
452
+ function formatShellCommand(command, args) {
453
+ return [command, ...args].map(quoteShellArg).join(" ");
454
+ }
455
+ function quoteShellArg(value) {
456
+ if (process.platform === "win32") {
457
+ return quoteWindowsCmdArg(value);
458
+ }
459
+ return `'${value.replace(/'/g, `'\\''`)}'`;
460
+ }
461
+ function quoteWindowsCmdArg(value) {
462
+ if (value.length === 0) {
463
+ return "\"\"";
464
+ }
465
+ if (!/[\s"&|<>()^%]/.test(value)) {
466
+ return value;
467
+ }
468
+ return `"${value
469
+ .replace(/%/g, "%%")
470
+ .replace(/(\\*)"/g, '$1$1\\"')
471
+ .replace(/(\\+)$/g, "$1$1")}"`;
472
+ }
421
473
  export function resolveNpmSpawnCommand(env = process.env) {
422
474
  const npmExecPath = env.npm_execpath?.trim();
423
475
  if (npmExecPath && existsSync(npmExecPath)) {
@@ -512,6 +564,14 @@ function parseVersionCacheTtlMs() {
512
564
  const parsed = Number(raw);
513
565
  return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_VERSION_CACHE_TTL_MS;
514
566
  }
567
+ function parseCliVersionCacheTtlMs() {
568
+ const raw = process.env.NORDRELAY_CLI_VERSION_CACHE_TTL_MS;
569
+ if (!raw) {
570
+ return DEFAULT_CLI_VERSION_CACHE_TTL_MS;
571
+ }
572
+ const parsed = Number(raw);
573
+ return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_CLI_VERSION_CACHE_TTL_MS;
574
+ }
515
575
  function readInstalledPackageVersion(packageName) {
516
576
  try {
517
577
  const packagePath = path.join(getSourceRoot(), "node_modules", ...packageName.split("/"), "package.json");
@@ -543,9 +603,6 @@ function compareVersions(left, right) {
543
603
  function parseVersionParts(value) {
544
604
  return value.split(/[.-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
545
605
  }
546
- function shellQuote(value) {
547
- return `'${value.replace(/'/g, `'\\''`)}'`;
548
- }
549
606
  function formatLogLine(line) {
550
607
  const trimmed = line.trim();
551
608
  if (!trimmed) {
@@ -105,10 +105,16 @@ export class RelayRuntime {
105
105
  };
106
106
  }
107
107
  async status() {
108
+ const cliOptions = this.cliPathOptions();
109
+ const [health, versionChecks, snapshot] = await Promise.all([
110
+ getConnectorHealth(cliOptions),
111
+ getVersionChecks(cliOptions),
112
+ this.snapshot(),
113
+ ]);
108
114
  return {
109
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
110
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
111
- snapshot: await this.snapshot(),
115
+ health,
116
+ versionChecks,
117
+ snapshot,
112
118
  };
113
119
  }
114
120
  async bootstrapStatus() {
@@ -121,10 +127,16 @@ export class RelayRuntime {
121
127
  };
122
128
  }
123
129
  async version() {
130
+ const cliOptions = this.cliPathOptions();
131
+ const [health, state, versionChecks] = await Promise.all([
132
+ getConnectorHealth(cliOptions),
133
+ readConnectorState(),
134
+ getVersionChecks(cliOptions),
135
+ ]);
124
136
  return {
125
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
126
- state: await readConnectorState(),
127
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
137
+ health,
138
+ state,
139
+ versionChecks,
128
140
  };
129
141
  }
130
142
  updateConnector() {
@@ -199,22 +211,32 @@ export class RelayRuntime {
199
211
  return this.agentUpdates.cancel(id);
200
212
  }
201
213
  async diagnostics() {
214
+ const cliOptions = this.cliPathOptions();
215
+ const [health, versionChecks, snapshot, session] = await Promise.all([
216
+ getConnectorHealth(cliOptions),
217
+ getVersionChecks(cliOptions),
218
+ this.snapshot(),
219
+ this.getSession(true),
220
+ ]);
202
221
  return {
203
- health: await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
204
- versionChecks: await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath }),
205
- snapshot: await this.snapshot(),
222
+ health,
223
+ versionChecks,
224
+ snapshot,
206
225
  runtime: {
207
226
  stateBackend: this.config.stateBackend,
208
227
  sourceWorkspace: this.config.workspace,
209
228
  queuePaused: this.queueService.isPaused(),
210
229
  externalMirror: this.externalActivityMonitor.snapshot(),
211
- agentDiagnostics: getAgentDiagnostics(await this.getSession(true), this.config),
230
+ agentDiagnostics: getAgentDiagnostics(session, this.config),
212
231
  },
213
232
  };
214
233
  }
215
234
  async adapterHealth() {
216
- const health = await getConnectorHealth({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
217
- const versions = await getVersionChecks({ piCliPath: this.config.piCliPath, hermesCliPath: this.config.hermesCliPath, openClawCliPath: this.config.openClawCliPath, claudeCodeCliPath: this.config.claudeCodeCliPath });
235
+ const cliOptions = this.cliPathOptions();
236
+ const [health, versions] = await Promise.all([
237
+ getConnectorHealth(cliOptions),
238
+ getVersionChecks(cliOptions),
239
+ ]);
218
240
  return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
219
241
  const enabled = enabledAgents(this.config).includes(descriptor.id);
220
242
  const auth = descriptor.capabilities.auth && enabled
@@ -945,6 +967,14 @@ export class RelayRuntime {
945
967
  });
946
968
  return { session, dispose: true };
947
969
  }
970
+ cliPathOptions() {
971
+ return {
972
+ piCliPath: this.config.piCliPath,
973
+ hermesCliPath: this.config.hermesCliPath,
974
+ openClawCliPath: this.config.openClawCliPath,
975
+ claudeCodeCliPath: this.config.claudeCodeCliPath,
976
+ };
977
+ }
948
978
  async ensureActiveThread(session) {
949
979
  if (!session.hasActiveThread()) {
950
980
  await session.newThread();
@@ -79,5 +79,8 @@ function tryCreateSqliteDocumentStore(options) {
79
79
  "ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at",
80
80
  ].join(" ")).run(options.sqliteKey, JSON.stringify(value), new Date().toISOString());
81
81
  },
82
+ close() {
83
+ db.close();
84
+ },
82
85
  };
83
86
  }
@@ -178,7 +178,8 @@ function systemInfo() {
178
178
  };
179
179
  }
180
180
  function detectNpmVersion(npm) {
181
- const result = spawnSync(npm.command, [...npm.argsPrefix, "--version"], {
181
+ const args = [...npm.argsPrefix, "--version"];
182
+ const result = spawnSync(npm.shell ? formatShellCommand(npm.command, args) : npm.command, npm.shell ? [] : args, {
182
183
  encoding: "utf8",
183
184
  shell: npm.shell,
184
185
  timeout: 3000,
@@ -189,6 +190,21 @@ function detectNpmVersion(npm) {
189
190
  }
190
191
  return String(result.stdout || "").trim() || null;
191
192
  }
193
+ function formatShellCommand(command, args) {
194
+ return [command, ...args].map(quoteWindowsCmdArg).join(" ");
195
+ }
196
+ function quoteWindowsCmdArg(value) {
197
+ if (value.length === 0) {
198
+ return "\"\"";
199
+ }
200
+ if (!/[\s"&|<>()^%]/.test(value)) {
201
+ return value;
202
+ }
203
+ return `"${value
204
+ .replace(/%/g, "%%")
205
+ .replace(/(\\*)"/g, '$1$1\\"')
206
+ .replace(/(\\+)$/g, "$1$1")}"`;
207
+ }
192
208
  function formatTimestamp(date) {
193
209
  return [
194
210
  date.getFullYear(),
@@ -584,6 +584,7 @@ function activeSettingsValues(current) {
584
584
  NORDRELAY_AUDIT_MAX_EVENTS: String(current.auditMaxEvents),
585
585
  NORDRELAY_SESSION_LOCK_TTL_MS: String(current.sessionLockTtlMs),
586
586
  NORDRELAY_VERSION_CACHE_TTL_MS: process.env.NORDRELAY_VERSION_CACHE_TTL_MS,
587
+ NORDRELAY_CLI_VERSION_CACHE_TTL_MS: process.env.NORDRELAY_CLI_VERSION_CACHE_TTL_MS,
587
588
  VOICE_PREFERRED_BACKEND: current.voicePreferredBackend,
588
589
  VOICE_DEFAULT_LANGUAGE: current.voiceDefaultLanguage,
589
590
  VOICE_TRANSCRIBE_ONLY: boolValue(current.voiceTranscribeOnly),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nordrelay",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",
@@ -51,6 +51,8 @@ function parseArgs(argv) {
51
51
  force: false,
52
52
  host: undefined,
53
53
  port: undefined,
54
+ restartAfterUpdate: true,
55
+ updateMethod: undefined,
54
56
  };
55
57
 
56
58
  for (let i = 0; i < copy.length; i += 1) {
@@ -60,6 +62,9 @@ function parseArgs(argv) {
60
62
  else if (arg === "--force") options.force = true;
61
63
  else if (arg === "--host") options.host = requireValue(copy, ++i, arg);
62
64
  else if (arg === "--port") options.port = Number.parseInt(requireValue(copy, ++i, arg), 10);
65
+ else if (arg === "--method") options.updateMethod = requireValue(copy, ++i, arg);
66
+ else if (arg === "--no-restart") options.restartAfterUpdate = false;
67
+ else if (arg === "--restart") options.restartAfterUpdate = true;
63
68
  else if (arg === "--token") options.telegramBotToken = requireValue(copy, ++i, arg);
64
69
  else if (arg === "--admin-email") options.adminEmail = requireValue(copy, ++i, arg);
65
70
  else if (arg === "--admin-name") options.adminName = requireValue(copy, ++i, arg);
@@ -326,6 +331,244 @@ async function commandStatus(options) {
326
331
  if (state.error) console.log(`Error: ${state.error}`);
327
332
  }
328
333
 
334
+ async function commandUpdate(options) {
335
+ await mkdirp(options.home);
336
+ loadEnvFiles(options.home);
337
+ const method = resolveUpdateMethod(options);
338
+ const updateLog = path.join(options.home, "update.log");
339
+ await mkdirp(path.dirname(updateLog));
340
+ const log = fs.createWriteStream(updateLog, { flags: "a" });
341
+ const sourceRoot = RUNTIME_ROOT;
342
+ const wasRunning = isProcessRunning(await readPid(options.pidFile));
343
+ const summary = method === "npm"
344
+ ? "Install latest @nordbyte/nordrelay with npm, verify the CLI, and restart if the connector is running."
345
+ : "Pull origin/main, install dependencies, run check, tests, build, and restart if the connector is running.";
346
+
347
+ console.log(`Starting NordRelay update (${method}).`);
348
+ console.log(`Source: ${sourceRoot}`);
349
+ console.log(`Log: ${updateLog}`);
350
+ logUpdateLine(log, `Starting ${method} connector self-update`);
351
+ logUpdateLine(log, summary);
352
+
353
+ try {
354
+ if (method === "npm") {
355
+ await runNpmSelfUpdate(sourceRoot, log);
356
+ } else {
357
+ await runGitSelfUpdate(sourceRoot, log);
358
+ }
359
+
360
+ if (options.restartAfterUpdate && wasRunning) {
361
+ await runLoggedStep(log, "Restart NordRelay connector", process.execPath, [
362
+ SCRIPT_PATH,
363
+ "restart",
364
+ "--keep-pending-updates",
365
+ "--home",
366
+ options.home,
367
+ ], { cwd: sourceRoot });
368
+ } else if (options.restartAfterUpdate) {
369
+ logUpdateLine(log, "Connector was not running; restart skipped.");
370
+ console.log("Connector was not running; restart skipped.");
371
+ } else {
372
+ logUpdateLine(log, "Restart skipped by --no-restart.");
373
+ console.log("Restart skipped by --no-restart.");
374
+ }
375
+
376
+ logUpdateLine(log, "NordRelay update completed.");
377
+ console.log("NordRelay update completed.");
378
+ } catch (error) {
379
+ const message = error instanceof Error ? error.message : String(error);
380
+ logUpdateLine(log, `ERROR ${message}`);
381
+ console.error(`Update failed: ${message}`);
382
+ process.exitCode = 1;
383
+ } finally {
384
+ await closeLogStream(log);
385
+ }
386
+ }
387
+
388
+ function resolveUpdateMethod(options) {
389
+ const requested = (options.updateMethod || process.env.NORDRELAY_UPDATE_METHOD || "auto").trim().toLowerCase();
390
+ if (!requested || requested === "auto") {
391
+ return fs.existsSync(path.join(RUNTIME_ROOT, ".git")) ? "git" : "npm";
392
+ }
393
+ if (requested === "npm" || requested === "git") {
394
+ return requested;
395
+ }
396
+ throw new Error(`Unsupported update method "${requested}". Use auto, npm, or git.`);
397
+ }
398
+
399
+ async function runNpmSelfUpdate(sourceRoot, log) {
400
+ const npm = resolveNpmSpawnCommand();
401
+ if (!npm) {
402
+ throw new Error("npm was not found. Install Node.js/npm or add npm to PATH.");
403
+ }
404
+ await runLoggedStep(log, "Install latest NordRelay package", npm.command, [
405
+ ...npm.argsPrefix,
406
+ "install",
407
+ "-g",
408
+ "@nordbyte/nordrelay@latest",
409
+ ], { cwd: os.homedir(), shell: npm.shell });
410
+ await runVerifyNordRelayCli(sourceRoot, log);
411
+ }
412
+
413
+ async function runGitSelfUpdate(sourceRoot, log) {
414
+ const git = resolveRequiredCommand("git");
415
+ const npm = resolveNpmSpawnCommand();
416
+ if (!npm) {
417
+ throw new Error("npm was not found. Install Node.js/npm or add npm to PATH.");
418
+ }
419
+ await runLoggedStep(log, "Pull latest source", git.command, ["pull", "--ff-only", "origin", "main"], { cwd: sourceRoot, shell: git.shell });
420
+ await runLoggedStep(log, "Install dependencies", npm.command, [...npm.argsPrefix, "install"], { cwd: sourceRoot, shell: npm.shell });
421
+ await runLoggedStep(log, "Run checks", npm.command, [...npm.argsPrefix, "run", "check"], { cwd: sourceRoot, shell: npm.shell });
422
+ await runLoggedStep(log, "Run tests", npm.command, [...npm.argsPrefix, "test"], { cwd: sourceRoot, shell: npm.shell });
423
+ await runLoggedStep(log, "Build runtime", npm.command, [...npm.argsPrefix, "run", "build"], { cwd: sourceRoot, shell: npm.shell });
424
+ await runVerifyNordRelayCli(sourceRoot, log);
425
+ }
426
+
427
+ async function runVerifyNordRelayCli(sourceRoot, log) {
428
+ if (fs.existsSync(SCRIPT_PATH)) {
429
+ await runLoggedStep(log, "Verify NordRelay CLI", process.execPath, [SCRIPT_PATH, "version"], { cwd: sourceRoot });
430
+ return;
431
+ }
432
+ const nordrelay = resolveRequiredCommand("nordrelay");
433
+ await runLoggedStep(log, "Verify NordRelay CLI", nordrelay.command, ["version"], { cwd: os.homedir(), shell: nordrelay.shell });
434
+ }
435
+
436
+ async function runLoggedStep(log, label, command, args, settings = {}) {
437
+ logUpdateLine(log, `${label}: ${formatCommand(command, args)}`);
438
+ console.log(`\n${label}`);
439
+ const useShell = Boolean(settings.shell);
440
+ const child = spawn(useShell ? formatShellCommand(command, args) : command, useShell ? [] : args, {
441
+ cwd: settings.cwd || RUNTIME_ROOT,
442
+ env: process.env,
443
+ shell: useShell,
444
+ stdio: ["inherit", "pipe", "pipe"],
445
+ windowsHide: false,
446
+ });
447
+
448
+ child.stdout?.on("data", (chunk) => {
449
+ safeWrite(process.stdout, chunk);
450
+ safeWrite(log, chunk);
451
+ });
452
+ child.stderr?.on("data", (chunk) => {
453
+ safeWrite(process.stderr, chunk);
454
+ safeWrite(log, chunk);
455
+ });
456
+
457
+ const exit = await new Promise((resolve, reject) => {
458
+ child.once("error", reject);
459
+ child.once("exit", (code, signal) => resolve({ code, signal }));
460
+ });
461
+
462
+ if (exit.signal) {
463
+ throw new Error(`${label} stopped with signal ${exit.signal}`);
464
+ }
465
+ if (exit.code !== 0) {
466
+ throw new Error(`${label} failed with exit code ${exit.code ?? "unknown"}`);
467
+ }
468
+ logUpdateLine(log, `${label} completed`);
469
+ }
470
+
471
+ function resolveRequiredCommand(command) {
472
+ const resolved = findExecutable(command);
473
+ if (!resolved) {
474
+ throw new Error(`${command} was not found on PATH.`);
475
+ }
476
+ return {
477
+ command: resolved,
478
+ shell: isWindowsShellScript(resolved),
479
+ };
480
+ }
481
+
482
+ function resolveNpmSpawnCommand(env = process.env) {
483
+ const npmExecPath = env.npm_execpath?.trim();
484
+ if (npmExecPath && fs.existsSync(npmExecPath)) {
485
+ return {
486
+ command: process.execPath,
487
+ argsPrefix: [npmExecPath],
488
+ shell: false,
489
+ };
490
+ }
491
+
492
+ const pathMatch = findExecutable("npm", env.PATH);
493
+ if (pathMatch) {
494
+ return {
495
+ command: pathMatch,
496
+ argsPrefix: [],
497
+ shell: isWindowsShellScript(pathMatch),
498
+ };
499
+ }
500
+
501
+ for (const candidate of commonNpmCandidates(env)) {
502
+ if (!fs.existsSync(candidate)) continue;
503
+ return {
504
+ command: candidate,
505
+ argsPrefix: [],
506
+ shell: isWindowsShellScript(candidate),
507
+ };
508
+ }
509
+ return null;
510
+ }
511
+
512
+ function commonNpmCandidates(env) {
513
+ const names = process.platform === "win32" ? ["npm.cmd", "npm.bat", "npm"] : ["npm"];
514
+ const directories = [
515
+ path.dirname(process.execPath),
516
+ env.APPDATA ? path.join(env.APPDATA, "npm") : undefined,
517
+ env.ProgramFiles ? path.join(env.ProgramFiles, "nodejs") : undefined,
518
+ env["ProgramFiles(x86)"] ? path.join(env["ProgramFiles(x86)"], "nodejs") : undefined,
519
+ ].filter(Boolean);
520
+ return directories.flatMap((directory) => names.map((name) => path.join(directory, name)));
521
+ }
522
+
523
+ function logUpdateLine(log, message) {
524
+ safeWrite(log, `[${nowIso()}] ${message}\n`);
525
+ }
526
+
527
+ function safeWrite(stream, chunk) {
528
+ try {
529
+ stream.write(chunk);
530
+ } catch {
531
+ // Logging must not break the updater if stdout/stderr disappears.
532
+ }
533
+ }
534
+
535
+ function closeLogStream(log) {
536
+ return new Promise((resolve) => {
537
+ log.end(resolve);
538
+ });
539
+ }
540
+
541
+ function formatCommand(command, args) {
542
+ return [command, ...args].map((part) => {
543
+ const text = String(part);
544
+ return /[\s"'$`\\]/.test(text) ? JSON.stringify(text) : text;
545
+ }).join(" ");
546
+ }
547
+
548
+ function formatShellCommand(command, args) {
549
+ return [command, ...args].map(quoteShellArg).join(" ");
550
+ }
551
+
552
+ function quoteShellArg(value) {
553
+ if (process.platform === "win32") {
554
+ return quoteWindowsCmdArg(String(value));
555
+ }
556
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
557
+ }
558
+
559
+ function quoteWindowsCmdArg(value) {
560
+ if (value.length === 0) {
561
+ return "\"\"";
562
+ }
563
+ if (!/[\s"&|<>()^%]/.test(value)) {
564
+ return value;
565
+ }
566
+ return `"${value
567
+ .replace(/%/g, "%%")
568
+ .replace(/(\\*)"/g, '$1$1\\"')
569
+ .replace(/(\\+)$/g, "$1$1")}"`;
570
+ }
571
+
329
572
  async function commandInit(options) {
330
573
  await mkdirp(options.home);
331
574
  const envPath = path.join(options.home, "nordrelay.env");
@@ -858,17 +1101,26 @@ function check(name, ok, detail, status = "fail") {
858
1101
  };
859
1102
  }
860
1103
 
861
- function findExecutable(command) {
1104
+ function findExecutable(command, pathValue = process.env.PATH, pathextValue = process.env.PATHEXT) {
862
1105
  if (!command) return null;
863
1106
  if (command.includes(path.sep) && fs.existsSync(command)) return command;
864
- const paths = (process.env.PATH || "").split(path.delimiter);
1107
+ const paths = (pathValue || "").split(path.delimiter);
1108
+ const extensions = process.platform === "win32"
1109
+ ? ["", ...(pathextValue || ".COM;.EXE;.BAT;.CMD").split(";")]
1110
+ : [""];
865
1111
  for (const dir of paths) {
866
- const candidate = path.join(dir, command);
867
- if (fs.existsSync(candidate)) return candidate;
1112
+ for (const extension of extensions) {
1113
+ const candidate = path.join(dir, `${command}${extension}`);
1114
+ if (fs.existsSync(candidate)) return candidate;
1115
+ }
868
1116
  }
869
1117
  return null;
870
1118
  }
871
1119
 
1120
+ function isWindowsShellScript(filePath) {
1121
+ return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
1122
+ }
1123
+
872
1124
  function validateStateBackend() {
873
1125
  const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
874
1126
  if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
@@ -920,6 +1172,7 @@ async function main() {
920
1172
  if (options.command === "init") return commandInit(options);
921
1173
  if (options.command === "user") return commandUser(options);
922
1174
  if (options.command === "doctor") return commandDoctor(options);
1175
+ if (options.command === "update") return commandUpdate(options);
923
1176
  if (options.command === "web" || options.command === "dashboard") return commandWeb(options);
924
1177
  if (options.command === "restart") {
925
1178
  await commandStop(options);
@@ -932,7 +1185,7 @@ async function main() {
932
1185
  }
933
1186
 
934
1187
  console.error(`Unknown command: ${options.command}`);
935
- console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|foreground|version]");
1188
+ console.error("Usage: nordrelay [init|user|doctor|web|start|stop|restart|status|update|foreground|version]");
936
1189
  process.exitCode = 2;
937
1190
  }
938
1191