@nordbyte/nordrelay 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 DEFAULT_HOME = path.join(os.homedir(), ".codex", "nordrelay");
10
+ const PACKAGE_NAME = "@nordbyte/nordrelay";
11
+ const CODEX_PACKAGE_NAME = "@openai/codex";
12
+ const PI_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
13
+ const DEFAULT_HOME = path.join(os.homedir(), ".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
+ }
@@ -1,14 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync } from "node:fs";
3
- import path from "node:path";
4
- import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
2
+ import { createDocumentStore } from "./state-backend.js";
5
3
  export class PromptStore {
6
- persistPath;
4
+ store;
7
5
  lastPrompts = new Map();
8
6
  queues = new Map();
9
7
  pausedContexts = new Set();
10
- constructor(workspace) {
11
- this.persistPath = path.join(workspace, ".nordrelay", "prompts.json");
8
+ constructor(workspace, backend = "json") {
9
+ this.store = createDocumentStore({
10
+ workspace,
11
+ fileName: "prompts.json",
12
+ sqliteKey: "prompts",
13
+ backend,
14
+ });
12
15
  this.load();
13
16
  }
14
17
  setLastPrompt(contextKey, prompt) {
@@ -18,12 +21,13 @@ export class PromptStore {
18
21
  getLastPrompt(contextKey) {
19
22
  return this.lastPrompts.get(contextKey);
20
23
  }
21
- enqueue(contextKey, prompt) {
24
+ enqueue(contextKey, prompt, options = {}) {
22
25
  const item = {
23
26
  ...prompt,
24
27
  id: createQueueId(),
25
28
  contextKey,
26
29
  createdAt: Date.now(),
30
+ notBefore: options.notBefore,
27
31
  };
28
32
  const queue = this.queues.get(contextKey) ?? [];
29
33
  queue.push(item);
@@ -39,7 +43,15 @@ export class PromptStore {
39
43
  }
40
44
  dequeue(contextKey) {
41
45
  const queue = this.queues.get(contextKey);
42
- const item = queue?.shift();
46
+ if (!queue || queue.length === 0) {
47
+ return undefined;
48
+ }
49
+ const now = Date.now();
50
+ const index = queue.findIndex((queued) => !queued.notBefore || queued.notBefore <= now);
51
+ if (index === -1) {
52
+ return undefined;
53
+ }
54
+ const [item] = queue.splice(index, 1);
43
55
  if (!queue || queue.length === 0) {
44
56
  this.queues.delete(contextKey);
45
57
  }
@@ -53,6 +65,16 @@ export class PromptStore {
53
65
  list(contextKey) {
54
66
  return [...(this.queues.get(contextKey) ?? [])];
55
67
  }
68
+ get(contextKey, id) {
69
+ return this.queues.get(contextKey)?.find((item) => item.id === id);
70
+ }
71
+ nextRunnableAt(contextKey) {
72
+ const timestamps = (this.queues.get(contextKey) ?? [])
73
+ .map((item) => item.notBefore)
74
+ .filter((value) => typeof value === "number")
75
+ .sort((left, right) => left - right);
76
+ return timestamps[0] ?? null;
77
+ }
56
78
  listContextKeys() {
57
79
  return [...new Set([...this.queues.keys(), ...this.pausedContexts])];
58
80
  }
@@ -143,13 +165,12 @@ export class PromptStore {
143
165
  }
144
166
  persist() {
145
167
  try {
146
- mkdirSync(path.dirname(this.persistPath), { recursive: true });
147
168
  const payload = {
148
169
  lastPrompts: Object.fromEntries(this.lastPrompts.entries()),
149
170
  queues: Object.fromEntries(this.queues.entries()),
150
171
  pausedContexts: [...this.pausedContexts],
151
172
  };
152
- writeJsonFileAtomic(this.persistPath, payload);
173
+ this.store.write(payload);
153
174
  }
154
175
  catch (error) {
155
176
  console.warn("Failed to persist prompt store:", error instanceof Error ? error.message : String(error));
@@ -157,7 +178,7 @@ export class PromptStore {
157
178
  }
158
179
  load() {
159
180
  try {
160
- const payload = readJsonFileWithBackup(this.persistPath).value;
181
+ const payload = this.store.read();
161
182
  if (!payload) {
162
183
  return;
163
184
  }
@@ -218,6 +239,7 @@ function isQueuedPrompt(value) {
218
239
  typeof value.id === "string" &&
219
240
  typeof value.contextKey === "string" &&
220
241
  typeof value.createdAt === "number" &&
242
+ (value.notBefore === undefined || typeof value.notBefore === "number") &&
221
243
  (value.updatedAt === undefined || typeof value.updatedAt === "number") &&
222
244
  (value.attempts === undefined || typeof value.attempts === "number") &&
223
245
  (value.lastError === undefined || typeof value.lastError === "string");