@nordbyte/nordrelay 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ const TELEGRAM_CAPABILITIES = [
2
+ "text",
3
+ "streaming-edits",
4
+ "typing",
5
+ "inline-buttons",
6
+ "files",
7
+ "photos",
8
+ "voice",
9
+ "topics",
10
+ "webhooks",
11
+ ];
12
+ const PLANNED_CHANNELS = [
13
+ {
14
+ id: "discord",
15
+ label: "Discord",
16
+ capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files", "photos", "voice"],
17
+ status: "planned",
18
+ notes: "Adapter boundary is ready; runtime integration still needs bot credentials and event mapping.",
19
+ },
20
+ {
21
+ id: "whatsapp",
22
+ label: "WhatsApp",
23
+ capabilities: ["text", "typing", "files", "photos", "voice", "webhooks"],
24
+ status: "planned",
25
+ notes: "Requires a WhatsApp Business provider integration.",
26
+ },
27
+ {
28
+ id: "slack",
29
+ label: "Slack",
30
+ capabilities: ["text", "streaming-edits", "typing", "inline-buttons", "files"],
31
+ status: "planned",
32
+ },
33
+ {
34
+ id: "matrix",
35
+ label: "Matrix",
36
+ capabilities: ["text", "files", "photos", "voice"],
37
+ status: "planned",
38
+ },
39
+ ];
40
+ export class TelegramChannelAdapter {
41
+ id = "telegram";
42
+ label = "Telegram";
43
+ capabilities = new Set(TELEGRAM_CAPABILITIES);
44
+ describe() {
45
+ return {
46
+ id: this.id,
47
+ label: this.label,
48
+ capabilities: [...this.capabilities],
49
+ status: "available",
50
+ };
51
+ }
52
+ }
53
+ export function listChannelDescriptors() {
54
+ return [
55
+ new TelegramChannelAdapter().describe(),
56
+ ...PLANNED_CHANNELS,
57
+ ];
58
+ }
package/dist/config.js CHANGED
@@ -24,9 +24,16 @@ export function loadConfig() {
24
24
  const telegramNotifyMode = parseNotifyMode(optionalString(process.env.TELEGRAM_NOTIFY_MODE), "minimal");
25
25
  const telegramQuietHours = parseQuietHours(optionalString(process.env.TELEGRAM_QUIET_HOURS));
26
26
  const telegramRedactPatterns = parseOptionalStringList(optionalString(process.env.TELEGRAM_REDACT_PATTERNS));
27
+ const telegramTransport = parseTelegramTransport(optionalString(process.env.TELEGRAM_TRANSPORT));
28
+ const telegramWebhookUrl = optionalString(process.env.TELEGRAM_WEBHOOK_URL);
29
+ const telegramWebhookHost = optionalString(process.env.TELEGRAM_WEBHOOK_HOST) ?? "127.0.0.1";
30
+ const telegramWebhookPort = parsePositiveIntegerEnv(optionalString(process.env.TELEGRAM_WEBHOOK_PORT), 8080, "TELEGRAM_WEBHOOK_PORT");
31
+ const telegramWebhookPath = parseWebhookPath(optionalString(process.env.TELEGRAM_WEBHOOK_PATH));
32
+ const telegramWebhookSecret = optionalString(process.env.TELEGRAM_WEBHOOK_SECRET);
27
33
  const workspace = resolveWorkspace();
28
34
  const workspaceAllowedRoots = parsePathList(optionalString(process.env.WORKSPACE_ALLOWED_ROOTS));
29
35
  const workspaceWarnRoots = parsePathList(optionalString(process.env.WORKSPACE_WARN_ROOTS));
36
+ const stateBackend = parseStateBackend(optionalString(process.env.NORDRELAY_STATE_BACKEND));
30
37
  const maxFileSize = parseMaxFileSize(optionalString(process.env.MAX_FILE_SIZE));
31
38
  const artifactRetentionDays = parsePositiveNumberEnv(optionalString(process.env.ARTIFACT_RETENTION_DAYS), 7, "ARTIFACT_RETENTION_DAYS");
32
39
  const artifactMaxTurnDirs = parsePositiveIntegerEnv(optionalString(process.env.ARTIFACT_MAX_TURNS), 30, "ARTIFACT_MAX_TURNS");
@@ -60,6 +67,11 @@ export function loadConfig() {
60
67
  const voicePreferredBackend = parseVoiceBackendPreference(optionalString(process.env.VOICE_PREFERRED_BACKEND));
61
68
  const voiceDefaultLanguage = optionalString(process.env.VOICE_DEFAULT_LANGUAGE);
62
69
  const voiceTranscribeOnly = parseBooleanEnv(optionalString(process.env.VOICE_TRANSCRIBE_ONLY), false);
70
+ const auditMaxEvents = parsePositiveIntegerEnv(optionalString(process.env.NORDRELAY_AUDIT_MAX_EVENTS), 1000, "NORDRELAY_AUDIT_MAX_EVENTS");
71
+ const sessionLockTtlMs = parseNonNegativeIntegerEnv(optionalString(process.env.NORDRELAY_SESSION_LOCK_TTL_MS), 30 * 60 * 1000, "NORDRELAY_SESSION_LOCK_TTL_MS");
72
+ if (telegramTransport === "webhook" && !telegramWebhookUrl) {
73
+ throw new Error("TELEGRAM_TRANSPORT=webhook requires TELEGRAM_WEBHOOK_URL");
74
+ }
63
75
  return {
64
76
  telegramBotToken,
65
77
  telegramAllowedUserIds,
@@ -79,9 +91,16 @@ export function loadConfig() {
79
91
  telegramNotifyMode,
80
92
  telegramQuietHours,
81
93
  telegramRedactPatterns,
94
+ telegramTransport,
95
+ telegramWebhookUrl,
96
+ telegramWebhookHost,
97
+ telegramWebhookPort,
98
+ telegramWebhookPath,
99
+ telegramWebhookSecret,
82
100
  workspace,
83
101
  workspaceAllowedRoots,
84
102
  workspaceWarnRoots,
103
+ stateBackend,
85
104
  maxFileSize,
86
105
  artifactRetentionDays,
87
106
  artifactMaxTurnDirs,
@@ -114,6 +133,8 @@ export function loadConfig() {
114
133
  voicePreferredBackend,
115
134
  voiceDefaultLanguage,
116
135
  voiceTranscribeOnly,
136
+ auditMaxEvents,
137
+ sessionLockTtlMs,
117
138
  };
118
139
  }
119
140
  /**
@@ -313,6 +334,32 @@ function parseLogFormat(raw) {
313
334
  console.warn(`Invalid CONNECTOR_LOG_FORMAT value: "${raw}". Expected text or json. Falling back to "text".`);
314
335
  return "text";
315
336
  }
337
+ function parseTelegramTransport(raw) {
338
+ if (!raw) {
339
+ return "polling";
340
+ }
341
+ if (raw === "polling" || raw === "webhook") {
342
+ return raw;
343
+ }
344
+ console.warn(`Invalid TELEGRAM_TRANSPORT value: "${raw}". Expected polling or webhook. Falling back to polling.`);
345
+ return "polling";
346
+ }
347
+ function parseWebhookPath(raw) {
348
+ if (!raw) {
349
+ return "/telegram/webhook";
350
+ }
351
+ return raw.startsWith("/") ? raw : `/${raw}`;
352
+ }
353
+ function parseStateBackend(raw) {
354
+ if (!raw) {
355
+ return "json";
356
+ }
357
+ if (raw === "json" || raw === "sqlite") {
358
+ return raw;
359
+ }
360
+ console.warn(`Invalid NORDRELAY_STATE_BACKEND value: "${raw}". Expected json or sqlite. Falling back to json.`);
361
+ return "json";
362
+ }
316
363
  function parseLaunchProfiles(raw, codexSandboxMode, codexApprovalPolicy, enableUnsafeLaunchProfiles) {
317
364
  const defaultProfile = createDefaultLaunchProfile(codexSandboxMode, codexApprovalPolicy);
318
365
  const profiles = createBuiltinLaunchProfiles(defaultProfile, {
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { createServer } from "node:http";
1
2
  import { mkdir, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import { webhookCallback } from "grammy";
3
5
  import { createBot, registerCommands } from "./bot.js";
4
6
  import { checkAuthStatus } from "./codex-auth.js";
5
7
  import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
@@ -12,8 +14,11 @@ import { configureRedaction } from "./redaction.js";
12
14
  import { SessionRegistry } from "./session-registry.js";
13
15
  let registry;
14
16
  let bot;
17
+ let webhookServer;
18
+ let runtimeConfig;
15
19
  try {
16
20
  const config = loadConfig();
21
+ runtimeConfig = config;
17
22
  configureRedaction(config.telegramRedactPatterns);
18
23
  installConsoleLogger(config.logFormat);
19
24
  registry = new SessionRegistry(config);
@@ -46,6 +51,7 @@ try {
46
51
  }
47
52
  }
48
53
  console.log("Session mode: per Telegram context");
54
+ console.log(`Telegram transport: ${config.telegramTransport}`);
49
55
  await writeConnectorState({
50
56
  status: "ready",
51
57
  pid: Number(process.env.NORDRELAY_WRAPPER_PID) || process.pid,
@@ -56,6 +62,7 @@ try {
56
62
  authMethod: authStatus.method,
57
63
  codexCli: describeCodexCli(codexCli),
58
64
  piCli: describePiCli(piCli),
65
+ telegramTransport: config.telegramTransport,
59
66
  });
60
67
  }
61
68
  catch (error) {
@@ -77,8 +84,9 @@ const shutdown = (signal) => {
77
84
  }
78
85
  shuttingDown = true;
79
86
  console.log(`Received ${signal}, shutting down NordRelay...`);
80
- if (bot)
87
+ if (bot && runtimeConfig?.telegramTransport !== "webhook")
81
88
  bot.stop();
89
+ webhookServer?.close();
82
90
  setTimeout(() => {
83
91
  registry?.disposeAll();
84
92
  void writeConnectorState({
@@ -124,7 +132,44 @@ async function startPolling() {
124
132
  process.exit(1);
125
133
  }
126
134
  }
127
- await startPolling();
135
+ if (registry && bot) {
136
+ if (runtimeConfig?.telegramTransport === "webhook") {
137
+ webhookServer = await startWebhook(bot, runtimeConfig);
138
+ }
139
+ else {
140
+ await startPolling();
141
+ }
142
+ }
143
+ async function startWebhook(activeBot, config) {
144
+ const callback = webhookCallback(activeBot, "http", {
145
+ secretToken: config.telegramWebhookSecret,
146
+ });
147
+ const server = createServer((req, res) => {
148
+ if (req.method === "GET" && req.url === "/healthz") {
149
+ res.writeHead(200, { "content-type": "text/plain" });
150
+ res.end("ok\n");
151
+ return;
152
+ }
153
+ if (req.url?.split("?")[0] !== config.telegramWebhookPath) {
154
+ res.writeHead(404, { "content-type": "text/plain" });
155
+ res.end("not found\n");
156
+ return;
157
+ }
158
+ void callback(req, res);
159
+ });
160
+ await activeBot.api.setWebhook(joinWebhookUrl(config.telegramWebhookUrl, config.telegramWebhookPath), {
161
+ secret_token: config.telegramWebhookSecret,
162
+ drop_pending_updates: process.env.NORDRELAY_DROP_PENDING_UPDATES !== "0",
163
+ });
164
+ await new Promise((resolve) => {
165
+ server.listen(config.telegramWebhookPort, config.telegramWebhookHost, resolve);
166
+ });
167
+ console.log(`Webhook listening on ${config.telegramWebhookHost}:${config.telegramWebhookPort}${config.telegramWebhookPath}`);
168
+ return server;
169
+ }
170
+ function joinWebhookUrl(baseUrl, webhookPath) {
171
+ return `${baseUrl.replace(/\/+$/, "")}${webhookPath.startsWith("/") ? webhookPath : `/${webhookPath}`}`;
172
+ }
128
173
  async function writeConnectorState(payload) {
129
174
  const stateFile = process.env.NORDRELAY_STATE_FILE;
130
175
  if (!stateFile) {
package/dist/logger.js CHANGED
@@ -1,9 +1,20 @@
1
1
  import { redactUnknown } from "./redaction.js";
2
2
  export function installConsoleLogger(format) {
3
+ const targetLog = console.log.bind(console);
4
+ const targetWarn = console.warn.bind(console);
5
+ const targetError = console.error.bind(console);
3
6
  if (format !== "json") {
7
+ console.log = (...args) => {
8
+ targetLog(toTextRecord("info", args));
9
+ };
10
+ console.warn = (...args) => {
11
+ targetWarn(toTextRecord("warn", args));
12
+ };
13
+ console.error = (...args) => {
14
+ targetError(toTextRecord("error", args));
15
+ };
4
16
  return;
5
17
  }
6
- const targetLog = console.log.bind(console);
7
18
  console.log = (...args) => {
8
19
  targetLog(JSON.stringify(toLogRecord("info", args)));
9
20
  };
@@ -14,6 +25,9 @@ export function installConsoleLogger(format) {
14
25
  targetLog(JSON.stringify(toLogRecord("error", args)));
15
26
  };
16
27
  }
28
+ function toTextRecord(level, args) {
29
+ return `[${formatLocalTimestamp(new Date())}] ${level.toUpperCase()} ${args.map(formatArg).join(" ")}`;
30
+ }
17
31
  function toLogRecord(level, args) {
18
32
  return {
19
33
  ts: new Date().toISOString(),
@@ -25,3 +39,12 @@ function toLogRecord(level, args) {
25
39
  function formatArg(value) {
26
40
  return redactUnknown(value);
27
41
  }
42
+ function formatLocalTimestamp(date) {
43
+ return [
44
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
45
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`,
46
+ ].join(" ");
47
+ }
48
+ function pad(value) {
49
+ return String(value).padStart(2, "0");
50
+ }
@@ -1,14 +1,18 @@
1
- import { spawn } from "node:child_process";
2
- import { closeSync, existsSync, openSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { readFile, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
7
7
  import { findLatestDatabase } from "./codex-state.js";
8
8
  import { describePiCli, resolvePiCli } from "./pi-cli.js";
9
9
  const APP_NAME = "nordrelay";
10
+ const PACKAGE_NAME = "@nordbyte/nordrelay";
11
+ const CODEX_PACKAGE_NAME = "@openai/codex";
12
+ const PI_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
10
13
  const DEFAULT_HOME = path.join(os.homedir(), ".codex", "nordrelay");
11
14
  const SECRET_RE = /(bot|token|api[_-]?key|authorization|bearer|password|secret)(["'=: ]+)([^\s"',]+)/gi;
15
+ const DEFAULT_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
12
16
  export function getConnectorHome() {
13
17
  return process.env.NORDRELAY_HOME || DEFAULT_HOME;
14
18
  }
@@ -39,6 +43,30 @@ export async function readLogTail(lines = 80, filePath = getConnectorLogPath())
39
43
  return `Cannot read log: ${error instanceof Error ? error.message : String(error)}`;
40
44
  }
41
45
  }
46
+ export async function readFormattedLogTail(lines = 80, filePath = getConnectorLogPath()) {
47
+ const boundedLines = Math.min(Math.max(lines, 1), 300);
48
+ try {
49
+ const [contents, stats] = await Promise.all([readFile(filePath, "utf8"), stat(filePath)]);
50
+ const rawLines = contents.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(-boundedLines);
51
+ const formatted = rawLines.map(formatLogLine).join("\n");
52
+ return {
53
+ filePath,
54
+ requestedLines: boundedLines,
55
+ lineCount: rawLines.length,
56
+ updatedAt: stats.mtime,
57
+ plain: redactSecrets(formatted),
58
+ };
59
+ }
60
+ catch (error) {
61
+ return {
62
+ filePath,
63
+ requestedLines: boundedLines,
64
+ lineCount: 0,
65
+ updatedAt: null,
66
+ plain: `Cannot read log: ${error instanceof Error ? error.message : String(error)}`,
67
+ };
68
+ }
69
+ }
42
70
  export async function getPackageVersion() {
43
71
  try {
44
72
  const pkg = JSON.parse(await readFile(path.join(getSourceRoot(), "package.json"), "utf8"));
@@ -48,18 +76,55 @@ export async function getPackageVersion() {
48
76
  return "unknown";
49
77
  }
50
78
  }
79
+ export async function getVersionChecks(options = {}) {
80
+ const nordrelayVersion = await getPackageVersion();
81
+ const codexCli = resolveCodexCli();
82
+ const piCli = resolvePiCli(process.env, options.piCliPath);
83
+ const codexVersionLabel = codexCli.path
84
+ ? detectCliVersion(codexCli.path)
85
+ : readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "not installed";
86
+ const piVersionLabel = piCli.path ? detectCliVersion(piCli.path) : "not installed";
87
+ return {
88
+ nordrelay: buildVersionCheck({
89
+ label: "NordRelay",
90
+ packageName: PACKAGE_NAME,
91
+ installedLabel: nordrelayVersion,
92
+ installedVersion: extractVersion(nordrelayVersion),
93
+ }),
94
+ codex: buildVersionCheck({
95
+ label: "Codex",
96
+ packageName: CODEX_PACKAGE_NAME,
97
+ installedLabel: codexVersionLabel,
98
+ installedVersion: extractVersion(codexVersionLabel),
99
+ notInstalled: codexVersionLabel === "not installed",
100
+ }),
101
+ pi: buildVersionCheck({
102
+ label: "Pi",
103
+ packageName: PI_PACKAGE_NAME,
104
+ installedLabel: piVersionLabel,
105
+ installedVersion: extractVersion(piVersionLabel),
106
+ notInstalled: piVersionLabel === "not installed",
107
+ }),
108
+ };
109
+ }
51
110
  export async function getConnectorHealth() {
52
111
  const state = await readConnectorState();
53
112
  const version = await getPackageVersion();
54
113
  const pidRunning = isProcessRunning(state.pid);
55
114
  const appPidRunning = isProcessRunning(state.appPid);
115
+ const codexCli = resolveCodexCli();
116
+ const piCli = resolvePiCli();
56
117
  return {
57
118
  version,
58
119
  state,
59
120
  pidRunning,
60
121
  appPidRunning,
61
- codexCli: describeCodexCli(resolveCodexCli()),
62
- piCli: describePiCli(resolvePiCli()),
122
+ codexCli: describeCodexCli(codexCli),
123
+ codexCliPath: codexCli.path ?? null,
124
+ codexCliVersion: detectCliVersion(codexCli.path),
125
+ piCli: describePiCli(piCli),
126
+ piCliPath: piCli.path ?? null,
127
+ piCliVersion: detectCliVersion(piCli.path),
63
128
  stateFile: getConnectorStatePath(),
64
129
  logFile: getConnectorLogPath(),
65
130
  databasePath: findLatestDatabase(),
@@ -80,17 +145,15 @@ export function spawnSelfUpdate() {
80
145
  const sourceRoot = getSourceRoot();
81
146
  const script = getWrapperScriptPath();
82
147
  const updateLog = getUpdateLogPath();
148
+ const method = detectSelfUpdateMethod(sourceRoot);
149
+ const commands = method === "npm"
150
+ ? buildNpmSelfUpdateCommands()
151
+ : buildGitSelfUpdateCommands(script);
83
152
  const logFd = openSync(updateLog, "a");
84
153
  const command = [
85
154
  "set -e",
86
- `printf '\\n[%s] Starting connector self-update\\n' "$(date -Is)"`,
87
- "git pull --ff-only origin main",
88
- "npm install",
89
- "npm run check",
90
- "npm test",
91
- "npm run build",
92
- `printf '[%s] Checks passed; restarting connector\\n' "$(date -Is)"`,
93
- `${shellQuote(process.execPath)} ${shellQuote(script)} restart --keep-pending-updates`,
155
+ `printf '\\n[%s] Starting ${method} connector self-update\\n' "$(date -Is)"`,
156
+ ...commands,
94
157
  ].join(" && ");
95
158
  const child = spawn("sh", ["-lc", command], {
96
159
  cwd: sourceRoot,
@@ -100,11 +163,25 @@ export function spawnSelfUpdate() {
100
163
  });
101
164
  child.unref();
102
165
  closeSync(logFd);
103
- return updateLog;
166
+ return {
167
+ logPath: updateLog,
168
+ method,
169
+ sourceRoot,
170
+ summary: method === "npm"
171
+ ? `Install latest ${PACKAGE_NAME} with npm, verify the CLI, and restart.`
172
+ : "Pull origin/main, install dependencies, run check, tests, build, and restart.",
173
+ };
104
174
  }
105
175
  export function getSourceRoot() {
106
176
  return process.env.NORDRELAY_SOURCE_ROOT || process.cwd();
107
177
  }
178
+ export function detectSelfUpdateMethod(sourceRoot = getSourceRoot()) {
179
+ const override = process.env.NORDRELAY_UPDATE_METHOD?.trim().toLowerCase();
180
+ if (override === "npm" || override === "git") {
181
+ return override;
182
+ }
183
+ return existsSync(path.join(sourceRoot, ".git")) ? "git" : "npm";
184
+ }
108
185
  function getWrapperScriptPath() {
109
186
  const sourceRoot = getSourceRoot();
110
187
  const script = path.join(sourceRoot, "plugins", APP_NAME, "scripts", `${APP_NAME}.mjs`);
@@ -128,6 +205,254 @@ function isProcessRunning(pid) {
128
205
  function redactSecrets(text) {
129
206
  return text.replace(SECRET_RE, "$1$2[redacted]");
130
207
  }
208
+ function buildGitSelfUpdateCommands(script) {
209
+ return [
210
+ "git pull --ff-only origin main",
211
+ "npm install",
212
+ "npm run check",
213
+ "npm test",
214
+ "npm run build",
215
+ `printf '[%s] Checks passed; restarting connector\\n' "$(date -Is)"`,
216
+ `${shellQuote(process.execPath)} ${shellQuote(script)} restart --keep-pending-updates`,
217
+ ];
218
+ }
219
+ function buildNpmSelfUpdateCommands() {
220
+ return [
221
+ `${resolveNpmCommand()} install -g ${PACKAGE_NAME}@latest`,
222
+ "nordrelay version",
223
+ `printf '[%s] npm update finished; restarting connector\\n' "$(date -Is)"`,
224
+ "nordrelay restart --keep-pending-updates",
225
+ ];
226
+ }
227
+ function resolveNpmCommand() {
228
+ const npmExecPath = process.env.npm_execpath;
229
+ if (npmExecPath && existsSync(npmExecPath)) {
230
+ return `${shellQuote(process.execPath)} ${shellQuote(npmExecPath)}`;
231
+ }
232
+ return "npm";
233
+ }
234
+ function detectCliVersion(commandPath) {
235
+ if (!commandPath) {
236
+ return "not installed";
237
+ }
238
+ const result = spawnSync(commandPath, ["--version"], {
239
+ encoding: "utf8",
240
+ timeout: 3000,
241
+ windowsHide: true,
242
+ });
243
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
244
+ if (result.error) {
245
+ return `unavailable (${result.error.message})`;
246
+ }
247
+ if (result.status !== 0) {
248
+ return output ? `unavailable (${output})` : `unavailable (exit ${result.status ?? "unknown"})`;
249
+ }
250
+ return output || "unknown";
251
+ }
252
+ function buildVersionCheck(options) {
253
+ if (options.notInstalled) {
254
+ return {
255
+ label: options.label,
256
+ packageName: options.packageName,
257
+ installedLabel: "not installed",
258
+ installedVersion: null,
259
+ latestVersion: null,
260
+ status: "not-installed",
261
+ };
262
+ }
263
+ const latest = detectLatestNpmVersion(options.packageName);
264
+ if (!options.installedVersion || !latest.version) {
265
+ return {
266
+ label: options.label,
267
+ packageName: options.packageName,
268
+ installedLabel: options.installedLabel,
269
+ installedVersion: options.installedVersion,
270
+ latestVersion: latest.version,
271
+ status: "unknown",
272
+ detail: latest.error ?? "Could not parse installed version",
273
+ };
274
+ }
275
+ return {
276
+ label: options.label,
277
+ packageName: options.packageName,
278
+ installedLabel: options.installedLabel,
279
+ installedVersion: options.installedVersion,
280
+ latestVersion: latest.version,
281
+ status: compareVersions(options.installedVersion, latest.version) < 0 ? "outdated" : "current",
282
+ detail: latest.error,
283
+ };
284
+ }
285
+ function detectLatestNpmVersion(packageName) {
286
+ const cached = readVersionCache(packageName);
287
+ if (cached) {
288
+ return cached;
289
+ }
290
+ const result = spawnSync("npm", ["view", packageName, "version", "--registry=https://registry.npmjs.org"], {
291
+ encoding: "utf8",
292
+ timeout: 5000,
293
+ windowsHide: true,
294
+ });
295
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
296
+ if (result.error) {
297
+ return { version: null, error: result.error.message };
298
+ }
299
+ if (result.status !== 0) {
300
+ return { version: null, error: output || `npm exited ${result.status ?? "unknown"}` };
301
+ }
302
+ const resolved = { version: output.split(/\r?\n/).at(-1)?.trim() || null };
303
+ writeVersionCache(packageName, resolved.version);
304
+ return resolved;
305
+ }
306
+ function readVersionCache(packageName) {
307
+ const ttlMs = parseVersionCacheTtlMs();
308
+ if (ttlMs <= 0) {
309
+ return null;
310
+ }
311
+ try {
312
+ const payload = JSON.parse(readFileSync(getVersionCachePath(), "utf8"));
313
+ const entry = payload.packages?.[packageName];
314
+ if (!entry || typeof entry.version !== "string" || typeof entry.checkedAt !== "number") {
315
+ return null;
316
+ }
317
+ if (Date.now() - entry.checkedAt > ttlMs) {
318
+ return null;
319
+ }
320
+ return { version: entry.version };
321
+ }
322
+ catch {
323
+ return null;
324
+ }
325
+ }
326
+ function writeVersionCache(packageName, version) {
327
+ if (!version || parseVersionCacheTtlMs() <= 0) {
328
+ return;
329
+ }
330
+ const filePath = getVersionCachePath();
331
+ try {
332
+ const existing = existsSync(filePath)
333
+ ? JSON.parse(readFileSync(filePath, "utf8"))
334
+ : {};
335
+ const packages = existing.packages ?? {};
336
+ packages[packageName] = { version, checkedAt: Date.now() };
337
+ mkdirSync(path.dirname(filePath), { recursive: true });
338
+ writeFileSync(filePath, `${JSON.stringify({ packages }, null, 2)}\n`, "utf8");
339
+ }
340
+ catch {
341
+ // Best-effort cache only.
342
+ }
343
+ }
344
+ function getVersionCachePath() {
345
+ return path.join(getConnectorHome(), "version-cache.json");
346
+ }
347
+ function parseVersionCacheTtlMs() {
348
+ const raw = process.env.NORDRELAY_VERSION_CACHE_TTL_MS;
349
+ if (!raw) {
350
+ return DEFAULT_VERSION_CACHE_TTL_MS;
351
+ }
352
+ const parsed = Number(raw);
353
+ return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_VERSION_CACHE_TTL_MS;
354
+ }
355
+ function readInstalledPackageVersion(packageName) {
356
+ try {
357
+ const packagePath = path.join(getSourceRoot(), "node_modules", ...packageName.split("/"), "package.json");
358
+ const pkg = JSON.parse(readFileSyncUtf8(packagePath));
359
+ return typeof pkg.version === "string" ? pkg.version : null;
360
+ }
361
+ catch {
362
+ return null;
363
+ }
364
+ }
365
+ function readFileSyncUtf8(filePath) {
366
+ return readFileSync(filePath, "utf8");
367
+ }
368
+ function extractVersion(value) {
369
+ const match = value.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/);
370
+ return match?.[0] ?? null;
371
+ }
372
+ function compareVersions(left, right) {
373
+ const leftParts = parseVersionParts(left);
374
+ const rightParts = parseVersionParts(right);
375
+ for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
376
+ const diff = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
377
+ if (diff !== 0) {
378
+ return diff;
379
+ }
380
+ }
381
+ return 0;
382
+ }
383
+ function parseVersionParts(value) {
384
+ return value.split(/[.-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
385
+ }
131
386
  function shellQuote(value) {
132
387
  return `'${value.replace(/'/g, `'\\''`)}'`;
133
388
  }
389
+ function formatLogLine(line) {
390
+ const trimmed = line.trim();
391
+ if (!trimmed) {
392
+ return "";
393
+ }
394
+ const parsedJson = parseJsonLogLine(trimmed);
395
+ if (parsedJson) {
396
+ return parsedJson;
397
+ }
398
+ const textRecord = trimmed.match(/^\[(?<timestamp>[^\]]+)\]\s+(?<level>INFO|WARN|ERROR)\s+(?<message>.*)$/i);
399
+ if (textRecord?.groups) {
400
+ return [
401
+ formatLogTimestamp(textRecord.groups.timestamp),
402
+ textRecord.groups.level.toUpperCase().padEnd(5),
403
+ textRecord.groups.message,
404
+ ].join(" ");
405
+ }
406
+ const timestampedShellLine = trimmed.match(/^\[(?<timestamp>[^\]]+)\]\s+(?<message>.*)$/);
407
+ if (timestampedShellLine?.groups) {
408
+ return [
409
+ formatLogTimestamp(timestampedShellLine.groups.timestamp),
410
+ "INFO ".padEnd(5),
411
+ timestampedShellLine.groups.message,
412
+ ].join(" ");
413
+ }
414
+ return trimmed;
415
+ }
416
+ function parseJsonLogLine(line) {
417
+ if (!line.startsWith("{")) {
418
+ return null;
419
+ }
420
+ try {
421
+ const parsed = JSON.parse(line);
422
+ const timestamp = typeof parsed.ts === "string"
423
+ ? parsed.ts
424
+ : typeof parsed.timestamp === "string"
425
+ ? parsed.timestamp
426
+ : null;
427
+ const level = typeof parsed.level === "string" ? parsed.level.toUpperCase() : "INFO";
428
+ const message = typeof parsed.message === "string" ? parsed.message : JSON.stringify(parsed);
429
+ const event = typeof parsed.event === "string" && parsed.event !== "console" ? ` ${parsed.event}` : "";
430
+ return [
431
+ formatLogTimestamp(timestamp),
432
+ `${level}${event}`.slice(0, 12).padEnd(12),
433
+ message,
434
+ ].join(" ");
435
+ }
436
+ catch {
437
+ return null;
438
+ }
439
+ }
440
+ function formatLogTimestamp(value) {
441
+ if (!value) {
442
+ return "unknown time".padEnd(25);
443
+ }
444
+ const parsed = new Date(value);
445
+ if (Number.isNaN(parsed.getTime())) {
446
+ return value.padEnd(25).slice(0, 25);
447
+ }
448
+ return formatLocalTimestamp(parsed);
449
+ }
450
+ function formatLocalTimestamp(date) {
451
+ return [
452
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
453
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`,
454
+ ].join(" ");
455
+ }
456
+ function pad(value) {
457
+ return String(value).padStart(2, "0");
458
+ }