@juspay/neurolink 9.34.0 → 9.35.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/CHANGELOG.md +6 -0
- package/dist/cli/commands/proxy.js +151 -4
- package/dist/lib/proxy/quietDetector.d.ts +22 -0
- package/dist/lib/proxy/quietDetector.js +112 -0
- package/dist/lib/proxy/updateChecker.d.ts +21 -0
- package/dist/lib/proxy/updateChecker.js +98 -0
- package/dist/lib/proxy/updateState.d.ts +67 -0
- package/dist/lib/proxy/updateState.js +153 -0
- package/dist/proxy/quietDetector.d.ts +22 -0
- package/dist/proxy/quietDetector.js +111 -0
- package/dist/proxy/updateChecker.d.ts +21 -0
- package/dist/proxy/updateChecker.js +97 -0
- package/dist/proxy/updateState.d.ts +67 -0
- package/dist/proxy/updateState.js +152 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [9.35.0](https://github.com/juspay/neurolink/compare/v9.34.0...v9.35.0) (2026-03-28)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- **(proxy):** add auto-update with traffic-aware graceful restart ([e0ea718](https://github.com/juspay/neurolink/commit/e0ea71891f51cf3d0f1f4fbc0044efc839359eaf))
|
|
6
|
+
|
|
1
7
|
## [9.34.0](https://github.com/juspay/neurolink/compare/v9.33.0...v9.34.0) (2026-03-27)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -16,6 +16,9 @@ import chalk from "chalk";
|
|
|
16
16
|
import ora from "ora";
|
|
17
17
|
import { logger } from "../../lib/utils/logger.js";
|
|
18
18
|
import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
const _require = createRequire(import.meta.url);
|
|
21
|
+
const { version: PROXY_VERSION } = _require("../../../package.json");
|
|
19
22
|
// =============================================================================
|
|
20
23
|
// STATE MANAGEMENT
|
|
21
24
|
// =============================================================================
|
|
@@ -193,7 +196,7 @@ function spawnFailOpenGuard(host, port, parentPid) {
|
|
|
193
196
|
return undefined;
|
|
194
197
|
}
|
|
195
198
|
try {
|
|
196
|
-
const
|
|
199
|
+
const args = [
|
|
197
200
|
entryScript,
|
|
198
201
|
"proxy",
|
|
199
202
|
"guard",
|
|
@@ -204,7 +207,8 @@ function spawnFailOpenGuard(host, port, parentPid) {
|
|
|
204
207
|
"--parent-pid",
|
|
205
208
|
String(parentPid),
|
|
206
209
|
"--quiet",
|
|
207
|
-
]
|
|
210
|
+
];
|
|
211
|
+
const child = spawn(process.execPath, args, {
|
|
208
212
|
detached: true,
|
|
209
213
|
stdio: "ignore",
|
|
210
214
|
});
|
|
@@ -545,6 +549,7 @@ export const proxyStartCommand = {
|
|
|
545
549
|
status: "ok",
|
|
546
550
|
strategy,
|
|
547
551
|
uptime: process.uptime(),
|
|
552
|
+
version: PROXY_VERSION,
|
|
548
553
|
}));
|
|
549
554
|
// Status endpoint (detailed)
|
|
550
555
|
app.get("/status", async (c) => {
|
|
@@ -557,6 +562,7 @@ export const proxyStartCommand = {
|
|
|
557
562
|
host,
|
|
558
563
|
strategy,
|
|
559
564
|
uptime: process.uptime(),
|
|
565
|
+
version: PROXY_VERSION,
|
|
560
566
|
stats: {
|
|
561
567
|
totalRequests: stats.totalRequests,
|
|
562
568
|
totalSuccess: stats.totalSuccess,
|
|
@@ -921,6 +927,146 @@ export const proxyGuardCommand = {
|
|
|
921
927
|
if (!Number.isFinite(parentPid) || parentPid <= 0) {
|
|
922
928
|
return;
|
|
923
929
|
}
|
|
930
|
+
// ---------------------------------------------------------------
|
|
931
|
+
// Auto-update loop (runs concurrently with the health monitor)
|
|
932
|
+
// Always on — no flags needed. Hardcoded sensible defaults.
|
|
933
|
+
// ---------------------------------------------------------------
|
|
934
|
+
const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
935
|
+
const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
|
|
936
|
+
const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
|
|
937
|
+
// Get running version from /health endpoint
|
|
938
|
+
let runningVersion = PROXY_VERSION; // fallback
|
|
939
|
+
try {
|
|
940
|
+
const healthResp = await fetch(`http://${host}:${port}/health`);
|
|
941
|
+
const healthData = (await healthResp.json());
|
|
942
|
+
runningVersion = healthData.version ?? PROXY_VERSION;
|
|
943
|
+
}
|
|
944
|
+
catch {
|
|
945
|
+
/* use fallback */
|
|
946
|
+
}
|
|
947
|
+
let updateInProgress = false;
|
|
948
|
+
let updateRestartInProgress = false;
|
|
949
|
+
const runUpdateCheck = async () => {
|
|
950
|
+
if (updateInProgress) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
updateInProgress = true;
|
|
954
|
+
try {
|
|
955
|
+
// Lazy-load update modules so they're only imported at check time
|
|
956
|
+
const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
|
|
957
|
+
const { checkTrafficQuiet } = await import("../../lib/proxy/quietDetector.js");
|
|
958
|
+
const { recordCheck, isVersionSuppressed, suppressVersion, recordSuccessfulUpdate, } = await import("../../lib/proxy/updateState.js");
|
|
959
|
+
// 1. Check for update
|
|
960
|
+
const result = await checkForUpdate(runningVersion);
|
|
961
|
+
recordCheck(result.latestVersion);
|
|
962
|
+
if (!result.updateAvailable) {
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (isVersionSuppressed(result.latestVersion)) {
|
|
966
|
+
logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
logger.always(`[guard] update available: ${runningVersion} → ${result.latestVersion}`);
|
|
970
|
+
// 2. Wait for quiet traffic
|
|
971
|
+
const maxQuietWaitMs = 60 * 60 * 1000; // 1 hour max wait
|
|
972
|
+
const quietPollMs = 10_000; // check every 10s
|
|
973
|
+
const quietStart = Date.now();
|
|
974
|
+
while (Date.now() - quietStart < maxQuietWaitMs) {
|
|
975
|
+
// Bail out if parent proxy died during the wait
|
|
976
|
+
if (getProcessStatus(parentPid) === "not_running") {
|
|
977
|
+
logger.always(`[guard] parent process died during quiet-wait, aborting update`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const quietStatus = checkTrafficQuiet(QUIET_THRESHOLD_MS);
|
|
981
|
+
if (quietStatus.isQuiet) {
|
|
982
|
+
break;
|
|
983
|
+
}
|
|
984
|
+
logger.debug(`[guard] traffic active (last activity ${Math.round(quietStatus.silenceDurationMs / 1000)}s ago), waiting...`);
|
|
985
|
+
await new Promise((r) => setTimeout(r, quietPollMs));
|
|
986
|
+
}
|
|
987
|
+
const finalQuiet = checkTrafficQuiet(QUIET_THRESHOLD_MS);
|
|
988
|
+
if (!finalQuiet.isQuiet) {
|
|
989
|
+
logger.always(`[guard] traffic didn't quiet down within 1 hour, skipping update cycle`);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
// 3. Install update (validate version string before passing to shell)
|
|
993
|
+
if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
|
|
994
|
+
logger.always(`[guard] WARNING: invalid version format "${result.latestVersion}", skipping`);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
logger.always(`[guard] traffic quiet, installing @juspay/neurolink@${result.latestVersion}...`);
|
|
998
|
+
const { execFileSync } = await import("node:child_process");
|
|
999
|
+
try {
|
|
1000
|
+
execFileSync("pnpm", ["add", "-g", `@juspay/neurolink@${result.latestVersion}`], {
|
|
1001
|
+
timeout: 120_000,
|
|
1002
|
+
stdio: "pipe",
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
catch (installErr) {
|
|
1006
|
+
logger.always(`[guard] WARNING: pnpm install failed: ${installErr instanceof Error ? installErr.message : String(installErr)}`);
|
|
1007
|
+
suppressVersion(result.latestVersion, "install_failed");
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
// 4. Restart via launchctl
|
|
1011
|
+
// Signal the health loop to not exit when it detects
|
|
1012
|
+
// the parent PID is gone — we're intentionally restarting.
|
|
1013
|
+
updateRestartInProgress = true;
|
|
1014
|
+
logger.always(`[guard] restarting proxy via launchctl...`);
|
|
1015
|
+
const uid = process.getuid?.() ?? 501;
|
|
1016
|
+
try {
|
|
1017
|
+
execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/com.neurolink.proxy`], {
|
|
1018
|
+
timeout: 10_000,
|
|
1019
|
+
stdio: "pipe",
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
logger.always(`[guard] WARNING: launchctl kickstart failed`);
|
|
1024
|
+
suppressVersion(result.latestVersion, "restart_failed");
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
// 5. Wait for healthy restart
|
|
1028
|
+
let healthy = false;
|
|
1029
|
+
const restartStart = Date.now();
|
|
1030
|
+
while (Date.now() - restartStart < UPDATE_TIMEOUT_MS) {
|
|
1031
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1032
|
+
try {
|
|
1033
|
+
const resp = await fetch(`http://${host}:${port}/health`, {
|
|
1034
|
+
signal: AbortSignal.timeout(3000),
|
|
1035
|
+
});
|
|
1036
|
+
if (resp.ok) {
|
|
1037
|
+
const data = (await resp.json());
|
|
1038
|
+
if (data.version === result.latestVersion) {
|
|
1039
|
+
healthy = true;
|
|
1040
|
+
break;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
catch {
|
|
1045
|
+
/* retry */
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (healthy) {
|
|
1049
|
+
logger.always(`[guard] update successful: now running ${result.latestVersion}`);
|
|
1050
|
+
recordSuccessfulUpdate(result.latestVersion);
|
|
1051
|
+
// The new proxy will spawn its own guard. Exit this one.
|
|
1052
|
+
process.exit(0);
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
logger.always(`[guard] WARNING: proxy unhealthy after update to ${result.latestVersion}`);
|
|
1056
|
+
suppressVersion(result.latestVersion, "unhealthy_after_restart");
|
|
1057
|
+
updateRestartInProgress = false;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
logger.always(`[guard] update check error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1062
|
+
}
|
|
1063
|
+
finally {
|
|
1064
|
+
updateInProgress = false;
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
// Run first check after a short delay, then on interval
|
|
1068
|
+
setTimeout(runUpdateCheck, 30_000);
|
|
1069
|
+
setInterval(runUpdateCheck, UPDATE_CHECK_INTERVAL_MS);
|
|
924
1070
|
const startedAt = Date.now();
|
|
925
1071
|
let parentStatus = getProcessStatus(parentPid);
|
|
926
1072
|
let consecutiveUnhealthy = 0;
|
|
@@ -933,8 +1079,9 @@ export const proxyGuardCommand = {
|
|
|
933
1079
|
else {
|
|
934
1080
|
consecutiveUnhealthy += 1;
|
|
935
1081
|
}
|
|
936
|
-
if (parentStatus === "not_running") {
|
|
937
|
-
// Parent is gone
|
|
1082
|
+
if (parentStatus === "not_running" && !updateRestartInProgress) {
|
|
1083
|
+
// Parent is gone (and we're not mid-update-restart).
|
|
1084
|
+
// If endpoint is still healthy, another proxy took over.
|
|
938
1085
|
if (healthy) {
|
|
939
1086
|
return;
|
|
940
1087
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Quiet Detector
|
|
3
|
+
* Determines whether the proxy has been idle (no traffic) for a given
|
|
4
|
+
* threshold by efficiently reading only the tail of today's debug log file.
|
|
5
|
+
* Used by the auto-update system to find safe windows for restarts.
|
|
6
|
+
*/
|
|
7
|
+
/** Result of a traffic-quiet check. */
|
|
8
|
+
export interface QuietStatus {
|
|
9
|
+
isQuiet: boolean;
|
|
10
|
+
lastActivityAt: Date | null;
|
|
11
|
+
silenceDurationMs: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Check whether proxy traffic has been quiet (no requests) for at least
|
|
15
|
+
* `quietThresholdMs` milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* Reads only the tail of today's debug log file for efficiency.
|
|
18
|
+
*
|
|
19
|
+
* @param quietThresholdMs Silence duration (ms) to consider "quiet". Default: 120 000 (2 min).
|
|
20
|
+
* @returns QuietStatus with the idle analysis.
|
|
21
|
+
*/
|
|
22
|
+
export declare function checkTrafficQuiet(quietThresholdMs?: number): QuietStatus;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Quiet Detector
|
|
3
|
+
* Determines whether the proxy has been idle (no traffic) for a given
|
|
4
|
+
* threshold by efficiently reading only the tail of today's debug log file.
|
|
5
|
+
* Used by the auto-update system to find safe windows for restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { openSync, readSync, closeSync, fstatSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
/** Default quiet threshold: 2 minutes of no traffic. */
|
|
11
|
+
const DEFAULT_QUIET_THRESHOLD_MS = 120_000;
|
|
12
|
+
/** Maximum bytes to read from the tail of the log file. */
|
|
13
|
+
const TAIL_READ_SIZE = 4096;
|
|
14
|
+
/**
|
|
15
|
+
* Build the path to today's proxy debug log file.
|
|
16
|
+
* Format: ~/.neurolink/logs/proxy-debug-YYYY-MM-DD.jsonl
|
|
17
|
+
*/
|
|
18
|
+
function getTodayLogPath() {
|
|
19
|
+
const today = new Date().toISOString().split("T")[0];
|
|
20
|
+
return join(homedir(), ".neurolink", "logs", `proxy-debug-${today}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read the last complete line(s) from a file efficiently.
|
|
24
|
+
* Uses low-level fs to seek to end and read only the last TAIL_READ_SIZE bytes.
|
|
25
|
+
* Returns an array of the last non-empty lines (up to 2 for fallback).
|
|
26
|
+
*/
|
|
27
|
+
function readTailLines(filePath) {
|
|
28
|
+
let fd = null;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(filePath, "r");
|
|
31
|
+
const stat = fstatSync(fd);
|
|
32
|
+
if (stat.size === 0) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const readSize = Math.min(TAIL_READ_SIZE, stat.size);
|
|
36
|
+
const offset = stat.size - readSize;
|
|
37
|
+
const buffer = Buffer.alloc(readSize);
|
|
38
|
+
readSync(fd, buffer, 0, readSize, offset);
|
|
39
|
+
const chunk = buffer.toString("utf-8");
|
|
40
|
+
// Split into lines, filter out empty trailing entries
|
|
41
|
+
const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
|
|
42
|
+
// Return last 2 lines (last + fallback)
|
|
43
|
+
return lines.slice(-2);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (fd !== null) {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Try to parse a JSON line and extract its ISO timestamp.
|
|
53
|
+
* Returns the timestamp as epoch ms, or null if parsing fails.
|
|
54
|
+
*/
|
|
55
|
+
function extractTimestamp(line) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(line);
|
|
58
|
+
if (typeof parsed.timestamp === "string") {
|
|
59
|
+
const ms = Date.parse(parsed.timestamp);
|
|
60
|
+
if (!Number.isNaN(ms)) {
|
|
61
|
+
return ms;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Malformed JSON — caller will handle fallback
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check whether proxy traffic has been quiet (no requests) for at least
|
|
72
|
+
* `quietThresholdMs` milliseconds.
|
|
73
|
+
*
|
|
74
|
+
* Reads only the tail of today's debug log file for efficiency.
|
|
75
|
+
*
|
|
76
|
+
* @param quietThresholdMs Silence duration (ms) to consider "quiet". Default: 120 000 (2 min).
|
|
77
|
+
* @returns QuietStatus with the idle analysis.
|
|
78
|
+
*/
|
|
79
|
+
export function checkTrafficQuiet(quietThresholdMs = DEFAULT_QUIET_THRESHOLD_MS) {
|
|
80
|
+
const noActivityResult = {
|
|
81
|
+
isQuiet: true,
|
|
82
|
+
lastActivityAt: null,
|
|
83
|
+
silenceDurationMs: Infinity,
|
|
84
|
+
};
|
|
85
|
+
const logPath = getTodayLogPath();
|
|
86
|
+
if (!existsSync(logPath)) {
|
|
87
|
+
return noActivityResult;
|
|
88
|
+
}
|
|
89
|
+
const tailLines = readTailLines(logPath);
|
|
90
|
+
if (tailLines.length === 0) {
|
|
91
|
+
return noActivityResult;
|
|
92
|
+
}
|
|
93
|
+
// Try last line first, then fall back to the one before it
|
|
94
|
+
let timestampMs = null;
|
|
95
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
96
|
+
timestampMs = extractTimestamp(tailLines[i]);
|
|
97
|
+
if (timestampMs !== null) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (timestampMs === null) {
|
|
102
|
+
// All tail lines are malformed — treat as quiet
|
|
103
|
+
return noActivityResult;
|
|
104
|
+
}
|
|
105
|
+
const silenceDurationMs = Date.now() - timestampMs;
|
|
106
|
+
return {
|
|
107
|
+
isQuiet: silenceDurationMs >= quietThresholdMs,
|
|
108
|
+
lastActivityAt: new Date(timestampMs),
|
|
109
|
+
silenceDurationMs,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=quietDetector.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy auto-update version checker.
|
|
3
|
+
*
|
|
4
|
+
* Queries the npm registry for the latest published version of
|
|
5
|
+
* `@juspay/neurolink` and compares it against the currently running version.
|
|
6
|
+
* Designed to be non-blocking and failure-tolerant — any error (network,
|
|
7
|
+
* timeout, parse) silently returns `updateAvailable: false`.
|
|
8
|
+
*/
|
|
9
|
+
export interface UpdateCheckResult {
|
|
10
|
+
currentVersion: string;
|
|
11
|
+
latestVersion: string;
|
|
12
|
+
updateAvailable: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Query npm for the latest version of `@juspay/neurolink` and compare it
|
|
16
|
+
* against {@link currentVersion}.
|
|
17
|
+
*
|
|
18
|
+
* On **any** failure the function resolves (never rejects) with
|
|
19
|
+
* `{ updateAvailable: false, latestVersion: currentVersion }`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkForUpdate(currentVersion: string): Promise<UpdateCheckResult>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy auto-update version checker.
|
|
3
|
+
*
|
|
4
|
+
* Queries the npm registry for the latest published version of
|
|
5
|
+
* `@juspay/neurolink` and compares it against the currently running version.
|
|
6
|
+
* Designed to be non-blocking and failure-tolerant — any error (network,
|
|
7
|
+
* timeout, parse) silently returns `updateAvailable: false`.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { logger } from "../utils/logger.js";
|
|
12
|
+
const execFile = promisify(execFileCb);
|
|
13
|
+
/** Timeout (ms) for the `npm view` child process. */
|
|
14
|
+
const NPM_VIEW_TIMEOUT_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a version string of the form `major.minor.patch` into numeric
|
|
17
|
+
* components. Returns `null` when the string does not match.
|
|
18
|
+
*/
|
|
19
|
+
function parseSemVer(version) {
|
|
20
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
21
|
+
if (!match) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
major: Number(match[1]),
|
|
26
|
+
minor: Number(match[2]),
|
|
27
|
+
patch: Number(match[3]),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when `latest` is strictly greater than `current`.
|
|
32
|
+
*
|
|
33
|
+
* Both arguments must be valid semver strings; returns `false` on any
|
|
34
|
+
* parse failure so the caller never sees a spurious "update available".
|
|
35
|
+
*/
|
|
36
|
+
function isNewerVersion(current, latest) {
|
|
37
|
+
const cur = parseSemVer(current);
|
|
38
|
+
const lat = parseSemVer(latest);
|
|
39
|
+
if (!cur || !lat) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (lat.major !== cur.major) {
|
|
43
|
+
return lat.major > cur.major;
|
|
44
|
+
}
|
|
45
|
+
if (lat.minor !== cur.minor) {
|
|
46
|
+
return lat.minor > cur.minor;
|
|
47
|
+
}
|
|
48
|
+
return lat.patch > cur.patch;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Core check
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Query npm for the latest version of `@juspay/neurolink` and compare it
|
|
55
|
+
* against {@link currentVersion}.
|
|
56
|
+
*
|
|
57
|
+
* On **any** failure the function resolves (never rejects) with
|
|
58
|
+
* `{ updateAvailable: false, latestVersion: currentVersion }`.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkForUpdate(currentVersion) {
|
|
61
|
+
const fail = {
|
|
62
|
+
currentVersion,
|
|
63
|
+
latestVersion: currentVersion,
|
|
64
|
+
updateAvailable: false,
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
logger.debug("[UpdateChecker] Checking for updates", { currentVersion });
|
|
68
|
+
const { stdout } = await execFile("npm", ["view", "@juspay/neurolink", "version", "--json"], { timeout: NPM_VIEW_TIMEOUT_MS });
|
|
69
|
+
// `npm view ... --json` wraps the value in double-quotes, e.g. `"9.32.0"`
|
|
70
|
+
const parsed = JSON.parse(stdout);
|
|
71
|
+
if (typeof parsed !== "string") {
|
|
72
|
+
logger.warn("[UpdateChecker] Unexpected npm output type", {
|
|
73
|
+
type: typeof parsed,
|
|
74
|
+
});
|
|
75
|
+
return fail;
|
|
76
|
+
}
|
|
77
|
+
const latestVersion = parsed.trim();
|
|
78
|
+
if (!parseSemVer(latestVersion)) {
|
|
79
|
+
logger.warn("[UpdateChecker] Failed to parse latest version", {
|
|
80
|
+
latestVersion,
|
|
81
|
+
});
|
|
82
|
+
return fail;
|
|
83
|
+
}
|
|
84
|
+
const updateAvailable = isNewerVersion(currentVersion, latestVersion);
|
|
85
|
+
logger.debug("[UpdateChecker] Version check complete", {
|
|
86
|
+
currentVersion,
|
|
87
|
+
latestVersion,
|
|
88
|
+
updateAvailable,
|
|
89
|
+
});
|
|
90
|
+
return { currentVersion, latestVersion, updateAvailable };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
logger.warn("[UpdateChecker] Update check failed", { error: message });
|
|
95
|
+
return fail;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=updateChecker.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update State Persistence
|
|
3
|
+
* Manages persistent state for the proxy auto-update feature.
|
|
4
|
+
* Tracks check timestamps, suppressed versions, and update history.
|
|
5
|
+
*
|
|
6
|
+
* State file location: ~/.neurolink/update-state.json
|
|
7
|
+
* Suppressed versions expire after 24 hours.
|
|
8
|
+
*/
|
|
9
|
+
export interface SuppressedVersion {
|
|
10
|
+
suppressedAt: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UpdateState {
|
|
14
|
+
lastCheckAt: string;
|
|
15
|
+
lastCheckVersion: string;
|
|
16
|
+
suppressedVersions: Record<string, SuppressedVersion>;
|
|
17
|
+
lastUpdateAt: string | null;
|
|
18
|
+
lastUpdateVersion: string | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Return an empty/initial UpdateState.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getDefaultUpdateState(): UpdateState;
|
|
24
|
+
/**
|
|
25
|
+
* Load the update state from disk.
|
|
26
|
+
* Returns null if the file does not exist.
|
|
27
|
+
* Returns the default state if the file contains corrupt JSON.
|
|
28
|
+
*
|
|
29
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadUpdateState(stateFilePath?: string): UpdateState | null;
|
|
32
|
+
/**
|
|
33
|
+
* Save the update state to disk.
|
|
34
|
+
*
|
|
35
|
+
* @param state - The UpdateState to persist
|
|
36
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
37
|
+
*/
|
|
38
|
+
export declare function saveUpdateState(state: UpdateState, stateFilePath?: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Check whether a version is currently suppressed (i.e., suppressed AND within the 24-hour window).
|
|
41
|
+
*
|
|
42
|
+
* @param version - Semver version string to check
|
|
43
|
+
* @param stateFilePath - Override path for testing
|
|
44
|
+
*/
|
|
45
|
+
export declare function isVersionSuppressed(version: string, stateFilePath?: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Add a version to the suppressed list and persist.
|
|
48
|
+
*
|
|
49
|
+
* @param version - Semver version string to suppress
|
|
50
|
+
* @param reason - Human-readable reason for suppression
|
|
51
|
+
* @param stateFilePath - Override path for testing
|
|
52
|
+
*/
|
|
53
|
+
export declare function suppressVersion(version: string, reason: string, stateFilePath?: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* Record a successful update: set lastUpdateAt and lastUpdateVersion, then persist.
|
|
56
|
+
*
|
|
57
|
+
* @param version - The version that was successfully installed
|
|
58
|
+
* @param stateFilePath - Override path for testing
|
|
59
|
+
*/
|
|
60
|
+
export declare function recordSuccessfulUpdate(version: string, stateFilePath?: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Record an update check: set lastCheckAt and lastCheckVersion, then persist.
|
|
63
|
+
*
|
|
64
|
+
* @param latestVersion - The latest version found during the check
|
|
65
|
+
* @param stateFilePath - Override path for testing
|
|
66
|
+
*/
|
|
67
|
+
export declare function recordCheck(latestVersion: string, stateFilePath?: string): void;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update State Persistence
|
|
3
|
+
* Manages persistent state for the proxy auto-update feature.
|
|
4
|
+
* Tracks check timestamps, suppressed versions, and update history.
|
|
5
|
+
*
|
|
6
|
+
* State file location: ~/.neurolink/update-state.json
|
|
7
|
+
* Suppressed versions expire after 24 hours.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
// ============================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================
|
|
15
|
+
const STATE_FILENAME = "update-state.json";
|
|
16
|
+
const SUPPRESSION_TTL_MS = 86_400_000; // 24 hours
|
|
17
|
+
// ============================================
|
|
18
|
+
// Internal Helpers
|
|
19
|
+
// ============================================
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the path to the update state file.
|
|
22
|
+
* Accepts an override for testing; defaults to ~/.neurolink/update-state.json.
|
|
23
|
+
*/
|
|
24
|
+
function resolveStatePath(overridePath) {
|
|
25
|
+
if (overridePath) {
|
|
26
|
+
return overridePath;
|
|
27
|
+
}
|
|
28
|
+
return path.join(os.homedir(), ".neurolink", STATE_FILENAME);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Ensure the parent directory of the given file path exists.
|
|
32
|
+
*/
|
|
33
|
+
function ensureParentDir(filePath) {
|
|
34
|
+
const dir = path.dirname(filePath);
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ============================================
|
|
40
|
+
// Exported Functions
|
|
41
|
+
// ============================================
|
|
42
|
+
/**
|
|
43
|
+
* Return an empty/initial UpdateState.
|
|
44
|
+
*/
|
|
45
|
+
export function getDefaultUpdateState() {
|
|
46
|
+
return {
|
|
47
|
+
lastCheckAt: new Date(0).toISOString(),
|
|
48
|
+
lastCheckVersion: "",
|
|
49
|
+
suppressedVersions: {},
|
|
50
|
+
lastUpdateAt: null,
|
|
51
|
+
lastUpdateVersion: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Load the update state from disk.
|
|
56
|
+
* Returns null if the file does not exist.
|
|
57
|
+
* Returns the default state if the file contains corrupt JSON.
|
|
58
|
+
*
|
|
59
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
60
|
+
*/
|
|
61
|
+
export function loadUpdateState(stateFilePath) {
|
|
62
|
+
const filePath = resolveStatePath(stateFilePath);
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(filePath)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
68
|
+
const parsed = JSON.parse(content);
|
|
69
|
+
// Minimal shape check — reject valid JSON that isn't an UpdateState
|
|
70
|
+
if (typeof parsed !== "object" ||
|
|
71
|
+
parsed === null ||
|
|
72
|
+
typeof parsed.suppressedVersions !== "object" ||
|
|
73
|
+
typeof parsed.lastCheckAt !== "string") {
|
|
74
|
+
return getDefaultUpdateState();
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Corrupt or unreadable JSON — return default state
|
|
80
|
+
return getDefaultUpdateState();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Save the update state to disk.
|
|
85
|
+
*
|
|
86
|
+
* @param state - The UpdateState to persist
|
|
87
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
88
|
+
*/
|
|
89
|
+
export function saveUpdateState(state, stateFilePath) {
|
|
90
|
+
const filePath = resolveStatePath(stateFilePath);
|
|
91
|
+
ensureParentDir(filePath);
|
|
92
|
+
// Atomic write: write to temp file then rename to prevent corruption on crash
|
|
93
|
+
const tmpPath = filePath + ".tmp";
|
|
94
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
|
|
95
|
+
fs.renameSync(tmpPath, filePath);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Check whether a version is currently suppressed (i.e., suppressed AND within the 24-hour window).
|
|
99
|
+
*
|
|
100
|
+
* @param version - Semver version string to check
|
|
101
|
+
* @param stateFilePath - Override path for testing
|
|
102
|
+
*/
|
|
103
|
+
export function isVersionSuppressed(version, stateFilePath) {
|
|
104
|
+
const state = loadUpdateState(stateFilePath);
|
|
105
|
+
if (!state) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const entry = state.suppressedVersions[version];
|
|
109
|
+
if (!entry) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return Date.now() - Date.parse(entry.suppressedAt) < SUPPRESSION_TTL_MS;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Add a version to the suppressed list and persist.
|
|
116
|
+
*
|
|
117
|
+
* @param version - Semver version string to suppress
|
|
118
|
+
* @param reason - Human-readable reason for suppression
|
|
119
|
+
* @param stateFilePath - Override path for testing
|
|
120
|
+
*/
|
|
121
|
+
export function suppressVersion(version, reason, stateFilePath) {
|
|
122
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
123
|
+
state.suppressedVersions[version] = {
|
|
124
|
+
suppressedAt: new Date().toISOString(),
|
|
125
|
+
reason,
|
|
126
|
+
};
|
|
127
|
+
saveUpdateState(state, stateFilePath);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Record a successful update: set lastUpdateAt and lastUpdateVersion, then persist.
|
|
131
|
+
*
|
|
132
|
+
* @param version - The version that was successfully installed
|
|
133
|
+
* @param stateFilePath - Override path for testing
|
|
134
|
+
*/
|
|
135
|
+
export function recordSuccessfulUpdate(version, stateFilePath) {
|
|
136
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
137
|
+
state.lastUpdateAt = new Date().toISOString();
|
|
138
|
+
state.lastUpdateVersion = version;
|
|
139
|
+
saveUpdateState(state, stateFilePath);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Record an update check: set lastCheckAt and lastCheckVersion, then persist.
|
|
143
|
+
*
|
|
144
|
+
* @param latestVersion - The latest version found during the check
|
|
145
|
+
* @param stateFilePath - Override path for testing
|
|
146
|
+
*/
|
|
147
|
+
export function recordCheck(latestVersion, stateFilePath) {
|
|
148
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
149
|
+
state.lastCheckAt = new Date().toISOString();
|
|
150
|
+
state.lastCheckVersion = latestVersion;
|
|
151
|
+
saveUpdateState(state, stateFilePath);
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=updateState.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Quiet Detector
|
|
3
|
+
* Determines whether the proxy has been idle (no traffic) for a given
|
|
4
|
+
* threshold by efficiently reading only the tail of today's debug log file.
|
|
5
|
+
* Used by the auto-update system to find safe windows for restarts.
|
|
6
|
+
*/
|
|
7
|
+
/** Result of a traffic-quiet check. */
|
|
8
|
+
export interface QuietStatus {
|
|
9
|
+
isQuiet: boolean;
|
|
10
|
+
lastActivityAt: Date | null;
|
|
11
|
+
silenceDurationMs: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Check whether proxy traffic has been quiet (no requests) for at least
|
|
15
|
+
* `quietThresholdMs` milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* Reads only the tail of today's debug log file for efficiency.
|
|
18
|
+
*
|
|
19
|
+
* @param quietThresholdMs Silence duration (ms) to consider "quiet". Default: 120 000 (2 min).
|
|
20
|
+
* @returns QuietStatus with the idle analysis.
|
|
21
|
+
*/
|
|
22
|
+
export declare function checkTrafficQuiet(quietThresholdMs?: number): QuietStatus;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Quiet Detector
|
|
3
|
+
* Determines whether the proxy has been idle (no traffic) for a given
|
|
4
|
+
* threshold by efficiently reading only the tail of today's debug log file.
|
|
5
|
+
* Used by the auto-update system to find safe windows for restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { openSync, readSync, closeSync, fstatSync, existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
/** Default quiet threshold: 2 minutes of no traffic. */
|
|
11
|
+
const DEFAULT_QUIET_THRESHOLD_MS = 120_000;
|
|
12
|
+
/** Maximum bytes to read from the tail of the log file. */
|
|
13
|
+
const TAIL_READ_SIZE = 4096;
|
|
14
|
+
/**
|
|
15
|
+
* Build the path to today's proxy debug log file.
|
|
16
|
+
* Format: ~/.neurolink/logs/proxy-debug-YYYY-MM-DD.jsonl
|
|
17
|
+
*/
|
|
18
|
+
function getTodayLogPath() {
|
|
19
|
+
const today = new Date().toISOString().split("T")[0];
|
|
20
|
+
return join(homedir(), ".neurolink", "logs", `proxy-debug-${today}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read the last complete line(s) from a file efficiently.
|
|
24
|
+
* Uses low-level fs to seek to end and read only the last TAIL_READ_SIZE bytes.
|
|
25
|
+
* Returns an array of the last non-empty lines (up to 2 for fallback).
|
|
26
|
+
*/
|
|
27
|
+
function readTailLines(filePath) {
|
|
28
|
+
let fd = null;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(filePath, "r");
|
|
31
|
+
const stat = fstatSync(fd);
|
|
32
|
+
if (stat.size === 0) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const readSize = Math.min(TAIL_READ_SIZE, stat.size);
|
|
36
|
+
const offset = stat.size - readSize;
|
|
37
|
+
const buffer = Buffer.alloc(readSize);
|
|
38
|
+
readSync(fd, buffer, 0, readSize, offset);
|
|
39
|
+
const chunk = buffer.toString("utf-8");
|
|
40
|
+
// Split into lines, filter out empty trailing entries
|
|
41
|
+
const lines = chunk.split("\n").filter((line) => line.trim().length > 0);
|
|
42
|
+
// Return last 2 lines (last + fallback)
|
|
43
|
+
return lines.slice(-2);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (fd !== null) {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Try to parse a JSON line and extract its ISO timestamp.
|
|
53
|
+
* Returns the timestamp as epoch ms, or null if parsing fails.
|
|
54
|
+
*/
|
|
55
|
+
function extractTimestamp(line) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(line);
|
|
58
|
+
if (typeof parsed.timestamp === "string") {
|
|
59
|
+
const ms = Date.parse(parsed.timestamp);
|
|
60
|
+
if (!Number.isNaN(ms)) {
|
|
61
|
+
return ms;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Malformed JSON — caller will handle fallback
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check whether proxy traffic has been quiet (no requests) for at least
|
|
72
|
+
* `quietThresholdMs` milliseconds.
|
|
73
|
+
*
|
|
74
|
+
* Reads only the tail of today's debug log file for efficiency.
|
|
75
|
+
*
|
|
76
|
+
* @param quietThresholdMs Silence duration (ms) to consider "quiet". Default: 120 000 (2 min).
|
|
77
|
+
* @returns QuietStatus with the idle analysis.
|
|
78
|
+
*/
|
|
79
|
+
export function checkTrafficQuiet(quietThresholdMs = DEFAULT_QUIET_THRESHOLD_MS) {
|
|
80
|
+
const noActivityResult = {
|
|
81
|
+
isQuiet: true,
|
|
82
|
+
lastActivityAt: null,
|
|
83
|
+
silenceDurationMs: Infinity,
|
|
84
|
+
};
|
|
85
|
+
const logPath = getTodayLogPath();
|
|
86
|
+
if (!existsSync(logPath)) {
|
|
87
|
+
return noActivityResult;
|
|
88
|
+
}
|
|
89
|
+
const tailLines = readTailLines(logPath);
|
|
90
|
+
if (tailLines.length === 0) {
|
|
91
|
+
return noActivityResult;
|
|
92
|
+
}
|
|
93
|
+
// Try last line first, then fall back to the one before it
|
|
94
|
+
let timestampMs = null;
|
|
95
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
96
|
+
timestampMs = extractTimestamp(tailLines[i]);
|
|
97
|
+
if (timestampMs !== null) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (timestampMs === null) {
|
|
102
|
+
// All tail lines are malformed — treat as quiet
|
|
103
|
+
return noActivityResult;
|
|
104
|
+
}
|
|
105
|
+
const silenceDurationMs = Date.now() - timestampMs;
|
|
106
|
+
return {
|
|
107
|
+
isQuiet: silenceDurationMs >= quietThresholdMs,
|
|
108
|
+
lastActivityAt: new Date(timestampMs),
|
|
109
|
+
silenceDurationMs,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy auto-update version checker.
|
|
3
|
+
*
|
|
4
|
+
* Queries the npm registry for the latest published version of
|
|
5
|
+
* `@juspay/neurolink` and compares it against the currently running version.
|
|
6
|
+
* Designed to be non-blocking and failure-tolerant — any error (network,
|
|
7
|
+
* timeout, parse) silently returns `updateAvailable: false`.
|
|
8
|
+
*/
|
|
9
|
+
export interface UpdateCheckResult {
|
|
10
|
+
currentVersion: string;
|
|
11
|
+
latestVersion: string;
|
|
12
|
+
updateAvailable: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Query npm for the latest version of `@juspay/neurolink` and compare it
|
|
16
|
+
* against {@link currentVersion}.
|
|
17
|
+
*
|
|
18
|
+
* On **any** failure the function resolves (never rejects) with
|
|
19
|
+
* `{ updateAvailable: false, latestVersion: currentVersion }`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkForUpdate(currentVersion: string): Promise<UpdateCheckResult>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy auto-update version checker.
|
|
3
|
+
*
|
|
4
|
+
* Queries the npm registry for the latest published version of
|
|
5
|
+
* `@juspay/neurolink` and compares it against the currently running version.
|
|
6
|
+
* Designed to be non-blocking and failure-tolerant — any error (network,
|
|
7
|
+
* timeout, parse) silently returns `updateAvailable: false`.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { logger } from "../utils/logger.js";
|
|
12
|
+
const execFile = promisify(execFileCb);
|
|
13
|
+
/** Timeout (ms) for the `npm view` child process. */
|
|
14
|
+
const NPM_VIEW_TIMEOUT_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a version string of the form `major.minor.patch` into numeric
|
|
17
|
+
* components. Returns `null` when the string does not match.
|
|
18
|
+
*/
|
|
19
|
+
function parseSemVer(version) {
|
|
20
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
21
|
+
if (!match) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
major: Number(match[1]),
|
|
26
|
+
minor: Number(match[2]),
|
|
27
|
+
patch: Number(match[3]),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns `true` when `latest` is strictly greater than `current`.
|
|
32
|
+
*
|
|
33
|
+
* Both arguments must be valid semver strings; returns `false` on any
|
|
34
|
+
* parse failure so the caller never sees a spurious "update available".
|
|
35
|
+
*/
|
|
36
|
+
function isNewerVersion(current, latest) {
|
|
37
|
+
const cur = parseSemVer(current);
|
|
38
|
+
const lat = parseSemVer(latest);
|
|
39
|
+
if (!cur || !lat) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (lat.major !== cur.major) {
|
|
43
|
+
return lat.major > cur.major;
|
|
44
|
+
}
|
|
45
|
+
if (lat.minor !== cur.minor) {
|
|
46
|
+
return lat.minor > cur.minor;
|
|
47
|
+
}
|
|
48
|
+
return lat.patch > cur.patch;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Core check
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Query npm for the latest version of `@juspay/neurolink` and compare it
|
|
55
|
+
* against {@link currentVersion}.
|
|
56
|
+
*
|
|
57
|
+
* On **any** failure the function resolves (never rejects) with
|
|
58
|
+
* `{ updateAvailable: false, latestVersion: currentVersion }`.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkForUpdate(currentVersion) {
|
|
61
|
+
const fail = {
|
|
62
|
+
currentVersion,
|
|
63
|
+
latestVersion: currentVersion,
|
|
64
|
+
updateAvailable: false,
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
logger.debug("[UpdateChecker] Checking for updates", { currentVersion });
|
|
68
|
+
const { stdout } = await execFile("npm", ["view", "@juspay/neurolink", "version", "--json"], { timeout: NPM_VIEW_TIMEOUT_MS });
|
|
69
|
+
// `npm view ... --json` wraps the value in double-quotes, e.g. `"9.32.0"`
|
|
70
|
+
const parsed = JSON.parse(stdout);
|
|
71
|
+
if (typeof parsed !== "string") {
|
|
72
|
+
logger.warn("[UpdateChecker] Unexpected npm output type", {
|
|
73
|
+
type: typeof parsed,
|
|
74
|
+
});
|
|
75
|
+
return fail;
|
|
76
|
+
}
|
|
77
|
+
const latestVersion = parsed.trim();
|
|
78
|
+
if (!parseSemVer(latestVersion)) {
|
|
79
|
+
logger.warn("[UpdateChecker] Failed to parse latest version", {
|
|
80
|
+
latestVersion,
|
|
81
|
+
});
|
|
82
|
+
return fail;
|
|
83
|
+
}
|
|
84
|
+
const updateAvailable = isNewerVersion(currentVersion, latestVersion);
|
|
85
|
+
logger.debug("[UpdateChecker] Version check complete", {
|
|
86
|
+
currentVersion,
|
|
87
|
+
latestVersion,
|
|
88
|
+
updateAvailable,
|
|
89
|
+
});
|
|
90
|
+
return { currentVersion, latestVersion, updateAvailable };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
logger.warn("[UpdateChecker] Update check failed", { error: message });
|
|
95
|
+
return fail;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update State Persistence
|
|
3
|
+
* Manages persistent state for the proxy auto-update feature.
|
|
4
|
+
* Tracks check timestamps, suppressed versions, and update history.
|
|
5
|
+
*
|
|
6
|
+
* State file location: ~/.neurolink/update-state.json
|
|
7
|
+
* Suppressed versions expire after 24 hours.
|
|
8
|
+
*/
|
|
9
|
+
export interface SuppressedVersion {
|
|
10
|
+
suppressedAt: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
export interface UpdateState {
|
|
14
|
+
lastCheckAt: string;
|
|
15
|
+
lastCheckVersion: string;
|
|
16
|
+
suppressedVersions: Record<string, SuppressedVersion>;
|
|
17
|
+
lastUpdateAt: string | null;
|
|
18
|
+
lastUpdateVersion: string | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Return an empty/initial UpdateState.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getDefaultUpdateState(): UpdateState;
|
|
24
|
+
/**
|
|
25
|
+
* Load the update state from disk.
|
|
26
|
+
* Returns null if the file does not exist.
|
|
27
|
+
* Returns the default state if the file contains corrupt JSON.
|
|
28
|
+
*
|
|
29
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
30
|
+
*/
|
|
31
|
+
export declare function loadUpdateState(stateFilePath?: string): UpdateState | null;
|
|
32
|
+
/**
|
|
33
|
+
* Save the update state to disk.
|
|
34
|
+
*
|
|
35
|
+
* @param state - The UpdateState to persist
|
|
36
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
37
|
+
*/
|
|
38
|
+
export declare function saveUpdateState(state: UpdateState, stateFilePath?: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Check whether a version is currently suppressed (i.e., suppressed AND within the 24-hour window).
|
|
41
|
+
*
|
|
42
|
+
* @param version - Semver version string to check
|
|
43
|
+
* @param stateFilePath - Override path for testing
|
|
44
|
+
*/
|
|
45
|
+
export declare function isVersionSuppressed(version: string, stateFilePath?: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Add a version to the suppressed list and persist.
|
|
48
|
+
*
|
|
49
|
+
* @param version - Semver version string to suppress
|
|
50
|
+
* @param reason - Human-readable reason for suppression
|
|
51
|
+
* @param stateFilePath - Override path for testing
|
|
52
|
+
*/
|
|
53
|
+
export declare function suppressVersion(version: string, reason: string, stateFilePath?: string): void;
|
|
54
|
+
/**
|
|
55
|
+
* Record a successful update: set lastUpdateAt and lastUpdateVersion, then persist.
|
|
56
|
+
*
|
|
57
|
+
* @param version - The version that was successfully installed
|
|
58
|
+
* @param stateFilePath - Override path for testing
|
|
59
|
+
*/
|
|
60
|
+
export declare function recordSuccessfulUpdate(version: string, stateFilePath?: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Record an update check: set lastCheckAt and lastCheckVersion, then persist.
|
|
63
|
+
*
|
|
64
|
+
* @param latestVersion - The latest version found during the check
|
|
65
|
+
* @param stateFilePath - Override path for testing
|
|
66
|
+
*/
|
|
67
|
+
export declare function recordCheck(latestVersion: string, stateFilePath?: string): void;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update State Persistence
|
|
3
|
+
* Manages persistent state for the proxy auto-update feature.
|
|
4
|
+
* Tracks check timestamps, suppressed versions, and update history.
|
|
5
|
+
*
|
|
6
|
+
* State file location: ~/.neurolink/update-state.json
|
|
7
|
+
* Suppressed versions expire after 24 hours.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
// ============================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================
|
|
15
|
+
const STATE_FILENAME = "update-state.json";
|
|
16
|
+
const SUPPRESSION_TTL_MS = 86_400_000; // 24 hours
|
|
17
|
+
// ============================================
|
|
18
|
+
// Internal Helpers
|
|
19
|
+
// ============================================
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the path to the update state file.
|
|
22
|
+
* Accepts an override for testing; defaults to ~/.neurolink/update-state.json.
|
|
23
|
+
*/
|
|
24
|
+
function resolveStatePath(overridePath) {
|
|
25
|
+
if (overridePath) {
|
|
26
|
+
return overridePath;
|
|
27
|
+
}
|
|
28
|
+
return path.join(os.homedir(), ".neurolink", STATE_FILENAME);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Ensure the parent directory of the given file path exists.
|
|
32
|
+
*/
|
|
33
|
+
function ensureParentDir(filePath) {
|
|
34
|
+
const dir = path.dirname(filePath);
|
|
35
|
+
if (!fs.existsSync(dir)) {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ============================================
|
|
40
|
+
// Exported Functions
|
|
41
|
+
// ============================================
|
|
42
|
+
/**
|
|
43
|
+
* Return an empty/initial UpdateState.
|
|
44
|
+
*/
|
|
45
|
+
export function getDefaultUpdateState() {
|
|
46
|
+
return {
|
|
47
|
+
lastCheckAt: new Date(0).toISOString(),
|
|
48
|
+
lastCheckVersion: "",
|
|
49
|
+
suppressedVersions: {},
|
|
50
|
+
lastUpdateAt: null,
|
|
51
|
+
lastUpdateVersion: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Load the update state from disk.
|
|
56
|
+
* Returns null if the file does not exist.
|
|
57
|
+
* Returns the default state if the file contains corrupt JSON.
|
|
58
|
+
*
|
|
59
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
60
|
+
*/
|
|
61
|
+
export function loadUpdateState(stateFilePath) {
|
|
62
|
+
const filePath = resolveStatePath(stateFilePath);
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(filePath)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
68
|
+
const parsed = JSON.parse(content);
|
|
69
|
+
// Minimal shape check — reject valid JSON that isn't an UpdateState
|
|
70
|
+
if (typeof parsed !== "object" ||
|
|
71
|
+
parsed === null ||
|
|
72
|
+
typeof parsed.suppressedVersions !== "object" ||
|
|
73
|
+
typeof parsed.lastCheckAt !== "string") {
|
|
74
|
+
return getDefaultUpdateState();
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Corrupt or unreadable JSON — return default state
|
|
80
|
+
return getDefaultUpdateState();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Save the update state to disk.
|
|
85
|
+
*
|
|
86
|
+
* @param state - The UpdateState to persist
|
|
87
|
+
* @param stateFilePath - Override path for testing (default: ~/.neurolink/update-state.json)
|
|
88
|
+
*/
|
|
89
|
+
export function saveUpdateState(state, stateFilePath) {
|
|
90
|
+
const filePath = resolveStatePath(stateFilePath);
|
|
91
|
+
ensureParentDir(filePath);
|
|
92
|
+
// Atomic write: write to temp file then rename to prevent corruption on crash
|
|
93
|
+
const tmpPath = filePath + ".tmp";
|
|
94
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2));
|
|
95
|
+
fs.renameSync(tmpPath, filePath);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Check whether a version is currently suppressed (i.e., suppressed AND within the 24-hour window).
|
|
99
|
+
*
|
|
100
|
+
* @param version - Semver version string to check
|
|
101
|
+
* @param stateFilePath - Override path for testing
|
|
102
|
+
*/
|
|
103
|
+
export function isVersionSuppressed(version, stateFilePath) {
|
|
104
|
+
const state = loadUpdateState(stateFilePath);
|
|
105
|
+
if (!state) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const entry = state.suppressedVersions[version];
|
|
109
|
+
if (!entry) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return Date.now() - Date.parse(entry.suppressedAt) < SUPPRESSION_TTL_MS;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Add a version to the suppressed list and persist.
|
|
116
|
+
*
|
|
117
|
+
* @param version - Semver version string to suppress
|
|
118
|
+
* @param reason - Human-readable reason for suppression
|
|
119
|
+
* @param stateFilePath - Override path for testing
|
|
120
|
+
*/
|
|
121
|
+
export function suppressVersion(version, reason, stateFilePath) {
|
|
122
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
123
|
+
state.suppressedVersions[version] = {
|
|
124
|
+
suppressedAt: new Date().toISOString(),
|
|
125
|
+
reason,
|
|
126
|
+
};
|
|
127
|
+
saveUpdateState(state, stateFilePath);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Record a successful update: set lastUpdateAt and lastUpdateVersion, then persist.
|
|
131
|
+
*
|
|
132
|
+
* @param version - The version that was successfully installed
|
|
133
|
+
* @param stateFilePath - Override path for testing
|
|
134
|
+
*/
|
|
135
|
+
export function recordSuccessfulUpdate(version, stateFilePath) {
|
|
136
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
137
|
+
state.lastUpdateAt = new Date().toISOString();
|
|
138
|
+
state.lastUpdateVersion = version;
|
|
139
|
+
saveUpdateState(state, stateFilePath);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Record an update check: set lastCheckAt and lastCheckVersion, then persist.
|
|
143
|
+
*
|
|
144
|
+
* @param latestVersion - The latest version found during the check
|
|
145
|
+
* @param stateFilePath - Override path for testing
|
|
146
|
+
*/
|
|
147
|
+
export function recordCheck(latestVersion, stateFilePath) {
|
|
148
|
+
const state = loadUpdateState(stateFilePath) ?? getDefaultUpdateState();
|
|
149
|
+
state.lastCheckAt = new Date().toISOString();
|
|
150
|
+
state.lastCheckVersion = latestVersion;
|
|
151
|
+
saveUpdateState(state, stateFilePath);
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.35.0",
|
|
4
4
|
"description": "Universal AI Development Platform with working MCP integration, multi-provider support, and professional CLI. Built-in tools operational, 58+ external MCP servers discoverable. Connect to filesystem, GitHub, database operations, and more. Build, test, and deploy AI applications with 13 providers: OpenAI, Anthropic, Google AI, AWS Bedrock, Azure, Hugging Face, Ollama, and Mistral AI.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Juspay Technologies",
|