@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.
- package/.env.example +22 -0
- package/CHANGELOG.md +17 -0
- package/README.md +130 -8
- package/dist/access-control.js +6 -0
- package/dist/agent-adapter.js +60 -0
- package/dist/audit-log.js +54 -0
- package/dist/bot-preferences.js +13 -9
- package/dist/bot-ui.js +6 -0
- package/dist/bot.js +521 -24
- package/dist/channel-adapter.js +58 -0
- package/dist/config.js +47 -0
- package/dist/index.js +47 -2
- package/dist/logger.js +24 -1
- package/dist/operations.js +339 -14
- package/dist/prompt-store.js +33 -11
- package/dist/relay-runtime.js +479 -0
- package/dist/session-locks.js +81 -0
- package/dist/session-registry.js +10 -6
- package/dist/settings-service.js +230 -0
- package/dist/state-backend.js +74 -0
- package/dist/web-dashboard.js +764 -0
- package/package.json +4 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +199 -2
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/operations.js
CHANGED
|
@@ -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(
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|