@juspay/neurolink 9.87.3 → 9.87.4
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 +7 -0
- package/dist/browser/neurolink.min.js +367 -367
- package/dist/cli/commands/proxy.d.ts +16 -1
- package/dist/cli/commands/proxy.js +275 -238
- package/dist/lib/proxy/globalInstaller.d.ts +5 -0
- package/dist/lib/proxy/globalInstaller.js +156 -0
- package/dist/lib/proxy/openaiFormat.d.ts +3 -1
- package/dist/lib/proxy/openaiFormat.js +19 -4
- package/dist/lib/proxy/proxyActivity.d.ts +8 -0
- package/dist/lib/proxy/proxyActivity.js +77 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +56 -6
- package/dist/lib/server/routes/claudeProxyRoutes.js +208 -50
- package/dist/lib/server/routes/openaiProxyRoutes.js +47 -6
- package/dist/lib/types/cli.d.ts +4 -0
- package/dist/lib/types/proxy.d.ts +49 -0
- package/dist/proxy/globalInstaller.d.ts +5 -0
- package/dist/proxy/globalInstaller.js +155 -0
- package/dist/proxy/openaiFormat.d.ts +3 -1
- package/dist/proxy/openaiFormat.js +19 -4
- package/dist/proxy/proxyActivity.d.ts +8 -0
- package/dist/proxy/proxyActivity.js +76 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +56 -6
- package/dist/server/routes/claudeProxyRoutes.js +208 -50
- package/dist/server/routes/openaiProxyRoutes.js +47 -6
- package/dist/types/cli.d.ts +4 -0
- package/dist/types/proxy.d.ts +49 -0
- package/package.json +1 -1
|
@@ -19,6 +19,8 @@ import { logger } from "../../lib/utils/logger.js";
|
|
|
19
19
|
import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
|
|
20
20
|
import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
|
|
21
21
|
import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js";
|
|
22
|
+
import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../lib/proxy/proxyActivity.js";
|
|
23
|
+
import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, } from "../../lib/proxy/globalInstaller.js";
|
|
22
24
|
import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
|
|
23
25
|
import { createRequire } from "node:module";
|
|
24
26
|
import { fileURLToPath } from "node:url";
|
|
@@ -137,7 +139,7 @@ function isLaunchdManagedProcess() {
|
|
|
137
139
|
return process.platform === "darwin" && process.ppid === 1;
|
|
138
140
|
}
|
|
139
141
|
function isProxyAutoUpdateEnabled(value = process.env.NEUROLINK_PROXY_AUTO_UPDATE) {
|
|
140
|
-
return ["
|
|
142
|
+
return !["0", "off", "false"].includes((value ?? "").trim().toLowerCase());
|
|
141
143
|
}
|
|
142
144
|
/** Keys we manage in Claude Code's settings.env */
|
|
143
145
|
const PROXY_MANAGED_KEYS = ["ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH"];
|
|
@@ -338,6 +340,41 @@ async function isProxyHealthy(host, port, timeoutMs) {
|
|
|
338
340
|
return false;
|
|
339
341
|
}
|
|
340
342
|
}
|
|
343
|
+
async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch(`http://${host}:${port}/status`, {
|
|
346
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
347
|
+
});
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const payload = (await response.json());
|
|
352
|
+
const activeRequests = Number(payload.activity?.activeRequests);
|
|
353
|
+
if (!Number.isFinite(activeRequests) || activeRequests < 0) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
activeRequests,
|
|
358
|
+
lastActivityAt: typeof payload.activity?.lastActivityAt === "string"
|
|
359
|
+
? payload.activity.lastActivityAt
|
|
360
|
+
: null,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function isSafeUpdateWindow(activity, quietThresholdMs, nowMs = Date.now()) {
|
|
368
|
+
if (activity.activeRequests > 0) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
if (!activity.lastActivityAt) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
const lastActivityMs = Date.parse(activity.lastActivityAt);
|
|
375
|
+
return (Number.isFinite(lastActivityMs) &&
|
|
376
|
+
nowMs - lastActivityMs >= quietThresholdMs);
|
|
377
|
+
}
|
|
341
378
|
// ---------------------------------------------------------------------------
|
|
342
379
|
// Stable entrypoint for launchd
|
|
343
380
|
// ---------------------------------------------------------------------------
|
|
@@ -437,113 +474,6 @@ exit 127
|
|
|
437
474
|
writeFileSync(TRAMPOLINE_PATH, script, { mode: 0o755 });
|
|
438
475
|
chmodSync(TRAMPOLINE_PATH, 0o755);
|
|
439
476
|
}
|
|
440
|
-
/**
|
|
441
|
-
* Check whether a pnpm binary can install into the global store.
|
|
442
|
-
*
|
|
443
|
-
* Multiple pnpm major versions can coexist (e.g., standalone v8 + nvm v10).
|
|
444
|
-
* They use different store layouts (`store/v10` vs `store/v10/v3`), so a
|
|
445
|
-
* pnpm that passes `--version` may still fail `pnpm add -g` with
|
|
446
|
-
* ERR_PNPM_UNEXPECTED_STORE. We detect this by running `pnpm root -g` and
|
|
447
|
-
* checking whether the resolved global root directory actually exists on disk.
|
|
448
|
-
*/
|
|
449
|
-
function canInstallGlobally(pnpmPath) {
|
|
450
|
-
try {
|
|
451
|
-
const { execFileSync } = _require("node:child_process");
|
|
452
|
-
const { existsSync } = _require("fs");
|
|
453
|
-
const globalRoot = execFileSync(pnpmPath, ["root", "-g"], {
|
|
454
|
-
encoding: "utf8",
|
|
455
|
-
timeout: 10_000,
|
|
456
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
457
|
-
}).trim();
|
|
458
|
-
// If the global root exists, this pnpm version is compatible with
|
|
459
|
-
// the current store layout and can install packages there.
|
|
460
|
-
return !!globalRoot && existsSync(globalRoot);
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
/**
|
|
467
|
-
* Resolve the `pnpm` binary defensively.
|
|
468
|
-
*
|
|
469
|
-
* Tries multiple candidates in order of preference. Each candidate must:
|
|
470
|
-
* 1. Respond to `pnpm --version` (binary works)
|
|
471
|
-
* 2. Have a compatible global store (`pnpm root -g` points to an existing dir)
|
|
472
|
-
*
|
|
473
|
-
* This defends against environments with multiple pnpm major versions
|
|
474
|
-
* (e.g., standalone v8 + nvm v10) where the wrong one would fail with
|
|
475
|
-
* ERR_PNPM_UNEXPECTED_STORE on `pnpm add -g`.
|
|
476
|
-
*
|
|
477
|
-
* Honors `NEUROLINK_PNPM_PATH` as an escape hatch.
|
|
478
|
-
*/
|
|
479
|
-
function resolveFullPnpmPath() {
|
|
480
|
-
const candidates = [];
|
|
481
|
-
// 1. User override
|
|
482
|
-
if (process.env.NEUROLINK_PNPM_PATH) {
|
|
483
|
-
candidates.push(process.env.NEUROLINK_PNPM_PATH);
|
|
484
|
-
}
|
|
485
|
-
// 2. PNPM_HOME (pnpm's own env variable)
|
|
486
|
-
if (process.env.PNPM_HOME) {
|
|
487
|
-
candidates.push(join(process.env.PNPM_HOME, "pnpm"));
|
|
488
|
-
}
|
|
489
|
-
// 3. `which pnpm` — whatever is on PATH
|
|
490
|
-
try {
|
|
491
|
-
const { execFileSync } = _require("node:child_process");
|
|
492
|
-
const whichOut = execFileSync("which", ["pnpm"], {
|
|
493
|
-
encoding: "utf8",
|
|
494
|
-
timeout: 5_000,
|
|
495
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
496
|
-
}).trim();
|
|
497
|
-
if (whichOut) {
|
|
498
|
-
candidates.push(whichOut);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
catch {
|
|
502
|
-
// ignore
|
|
503
|
-
}
|
|
504
|
-
// 4. Common standalone installer locations
|
|
505
|
-
candidates.push(join(homedir(), ".local", "share", "pnpm", "pnpm"));
|
|
506
|
-
candidates.push(join(homedir(), "Library", "pnpm", "pnpm"));
|
|
507
|
-
// Dedupe while preserving order
|
|
508
|
-
const seen = new Set();
|
|
509
|
-
const unique = candidates.filter((p) => {
|
|
510
|
-
if (!p || seen.has(p)) {
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
seen.add(p);
|
|
514
|
-
return true;
|
|
515
|
-
});
|
|
516
|
-
// Probe each candidate: must pass --version AND have a compatible global store
|
|
517
|
-
const tried = unique.map((path) => {
|
|
518
|
-
const version = probeBinVersion(path);
|
|
519
|
-
const working = version !== undefined;
|
|
520
|
-
const globalStoreOk = working ? canInstallGlobally(path) : false;
|
|
521
|
-
return { path, version, working, globalStoreOk };
|
|
522
|
-
});
|
|
523
|
-
// Prefer a candidate that can actually install globally
|
|
524
|
-
const fullyWorking = tried.find((r) => r.working && r.globalStoreOk);
|
|
525
|
-
if (fullyWorking) {
|
|
526
|
-
return {
|
|
527
|
-
bin: fullyWorking.path,
|
|
528
|
-
resolved: true,
|
|
529
|
-
version: fullyWorking.version,
|
|
530
|
-
tried,
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
// Fall back to any candidate that at least responds to --version
|
|
534
|
-
// (better than nothing — the install may still fail, but will be
|
|
535
|
-
// caught and suppressed by the caller)
|
|
536
|
-
const anyWorking = tried.find((r) => r.working);
|
|
537
|
-
if (anyWorking) {
|
|
538
|
-
return {
|
|
539
|
-
bin: anyWorking.path,
|
|
540
|
-
resolved: true,
|
|
541
|
-
version: anyWorking.version,
|
|
542
|
-
tried,
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
return { bin: "pnpm", resolved: false, tried };
|
|
546
|
-
}
|
|
547
477
|
function spawnFailOpenGuard(host, port, parentPid) {
|
|
548
478
|
// The guard runs the same version as this process, so process.argv[1]
|
|
549
479
|
// (the currently-running script) is correct here — no stale-path risk.
|
|
@@ -587,6 +517,52 @@ function spawnFailOpenGuard(host, port, parentPid) {
|
|
|
587
517
|
closeSync(logFd); // parent closes its copy; child keeps the fd
|
|
588
518
|
}
|
|
589
519
|
}
|
|
520
|
+
function spawnProxyUpdater(host, port, parentPid) {
|
|
521
|
+
if (!isProxyAutoUpdateEnabled()) {
|
|
522
|
+
logger.always("[proxy] automatic updates disabled by environment");
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
const entryScript = process.argv[1];
|
|
526
|
+
if (!entryScript) {
|
|
527
|
+
logger.always("[proxy] updater disabled: CLI entry script is unavailable");
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
const args = [
|
|
531
|
+
entryScript,
|
|
532
|
+
"proxy",
|
|
533
|
+
"guard",
|
|
534
|
+
"--host",
|
|
535
|
+
host,
|
|
536
|
+
"--port",
|
|
537
|
+
String(port),
|
|
538
|
+
"--parent-pid",
|
|
539
|
+
String(parentPid),
|
|
540
|
+
"--updater-only",
|
|
541
|
+
"--quiet",
|
|
542
|
+
];
|
|
543
|
+
const { openSync, closeSync, mkdirSync, existsSync } = _require("fs");
|
|
544
|
+
const logsDir = join(homedir(), ".neurolink", "logs");
|
|
545
|
+
if (!existsSync(logsDir)) {
|
|
546
|
+
mkdirSync(logsDir, { recursive: true });
|
|
547
|
+
}
|
|
548
|
+
const logFd = openSync(join(logsDir, "proxy-updater.log"), "a");
|
|
549
|
+
try {
|
|
550
|
+
const child = spawn(process.execPath, args, {
|
|
551
|
+
detached: true,
|
|
552
|
+
stdio: ["ignore", logFd, logFd],
|
|
553
|
+
env: process.env,
|
|
554
|
+
});
|
|
555
|
+
child.unref();
|
|
556
|
+
return child.pid;
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
logger.always(`[proxy] updater failed to start: ${error instanceof Error ? error.message : String(error)}`);
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
closeSync(logFd);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
590
566
|
async function runProxyTelemetryManager(command) {
|
|
591
567
|
const { existsSync } = await import("fs");
|
|
592
568
|
if (!existsSync(PROXY_TELEMETRY_SCRIPT_PATH)) {
|
|
@@ -826,26 +802,100 @@ async function resolveBootPrimaryAccountKey(primaryEmail) {
|
|
|
826
802
|
}
|
|
827
803
|
return key;
|
|
828
804
|
}
|
|
829
|
-
async function createProxyStartApp(params) {
|
|
805
|
+
export async function createProxyStartApp(params) {
|
|
830
806
|
const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
|
|
831
807
|
const { createOpenAIProxyRoutes } = await import("../../lib/server/routes/openaiProxyRoutes.js");
|
|
808
|
+
const { logBodyCapture, logRequest } = await import("../../lib/proxy/requestLogger.js");
|
|
809
|
+
const { recordFinalError } = await import("../../lib/proxy/usageStats.js");
|
|
832
810
|
const { Hono } = await import("hono");
|
|
833
811
|
const app = new Hono();
|
|
834
812
|
const readiness = createProxyReadinessState();
|
|
835
|
-
|
|
813
|
+
const requestMetadata = new WeakMap();
|
|
814
|
+
const recordRuntimeError = async (metadata, status, errorType, errorMessage, clientMessage = errorMessage, clientErrorType = errorType) => {
|
|
815
|
+
recordFinalError(status);
|
|
816
|
+
await Promise.all([
|
|
817
|
+
logRequest({
|
|
818
|
+
timestamp: new Date().toISOString(),
|
|
819
|
+
requestId: metadata.requestId,
|
|
820
|
+
method: metadata.method,
|
|
821
|
+
path: metadata.path,
|
|
822
|
+
model: metadata.model,
|
|
823
|
+
stream: metadata.stream,
|
|
824
|
+
toolCount: metadata.toolCount,
|
|
825
|
+
account: "",
|
|
826
|
+
accountType: "proxy-runtime",
|
|
827
|
+
responseStatus: status,
|
|
828
|
+
responseTimeMs: Date.now() - metadata.startedAt,
|
|
829
|
+
errorType,
|
|
830
|
+
errorMessage,
|
|
831
|
+
}),
|
|
832
|
+
logBodyCapture({
|
|
833
|
+
timestamp: new Date().toISOString(),
|
|
834
|
+
requestId: metadata.requestId,
|
|
835
|
+
model: metadata.model,
|
|
836
|
+
stream: metadata.stream,
|
|
837
|
+
phase: "client_response",
|
|
838
|
+
headers: { "content-type": "application/json" },
|
|
839
|
+
body: {
|
|
840
|
+
type: "error",
|
|
841
|
+
error: { type: clientErrorType, message: clientMessage },
|
|
842
|
+
},
|
|
843
|
+
contentType: "application/json",
|
|
844
|
+
responseStatus: status,
|
|
845
|
+
durationMs: Date.now() - metadata.startedAt,
|
|
846
|
+
}),
|
|
847
|
+
]);
|
|
848
|
+
};
|
|
849
|
+
app.onError(async (err, c) => {
|
|
836
850
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
837
851
|
logger.always(`[proxy] unhandled error: ${errMsg}`);
|
|
838
852
|
if (err instanceof Error && err.stack) {
|
|
839
|
-
logger.
|
|
840
|
-
}
|
|
853
|
+
logger.always(`[proxy] unhandled stack: ${err.stack}`);
|
|
854
|
+
}
|
|
855
|
+
const metadata = requestMetadata.get(c.req.raw) ?? {
|
|
856
|
+
requestId: crypto.randomUUID(),
|
|
857
|
+
method: c.req.method,
|
|
858
|
+
path: c.req.path,
|
|
859
|
+
startedAt: Date.now(),
|
|
860
|
+
model: "-",
|
|
861
|
+
stream: false,
|
|
862
|
+
toolCount: 0,
|
|
863
|
+
};
|
|
864
|
+
await recordRuntimeError(metadata, 502, "unhandled_proxy_error", errMsg, "Proxy internal error", "api_error");
|
|
865
|
+
requestMetadata.delete(c.req.raw);
|
|
841
866
|
return c.json({
|
|
842
867
|
type: "error",
|
|
843
868
|
error: {
|
|
844
869
|
type: "api_error",
|
|
845
|
-
message:
|
|
870
|
+
message: "Proxy internal error",
|
|
846
871
|
},
|
|
847
872
|
}, 502);
|
|
848
873
|
});
|
|
874
|
+
app.use("/v1/*", async (c, next) => {
|
|
875
|
+
const metadata = {
|
|
876
|
+
requestId: crypto.randomUUID(),
|
|
877
|
+
method: c.req.method,
|
|
878
|
+
path: c.req.path,
|
|
879
|
+
startedAt: Date.now(),
|
|
880
|
+
model: "-",
|
|
881
|
+
stream: false,
|
|
882
|
+
toolCount: 0,
|
|
883
|
+
};
|
|
884
|
+
requestMetadata.set(c.req.raw, metadata);
|
|
885
|
+
const finishActivity = beginProxyRequest();
|
|
886
|
+
const finish = () => {
|
|
887
|
+
finishActivity();
|
|
888
|
+
requestMetadata.delete(c.req.raw);
|
|
889
|
+
};
|
|
890
|
+
try {
|
|
891
|
+
await next();
|
|
892
|
+
c.res = trackProxyResponse(c.res, finish);
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
finishActivity();
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
});
|
|
849
899
|
const routeGroup = createClaudeProxyRoutes(params.modelRouter, "", params.strategy, params.passthrough, params.primaryAccountKey, params.accountAllowlist);
|
|
850
900
|
const openaiRouteGroup = createOpenAIProxyRoutes(params.modelRouter, "", params.port);
|
|
851
901
|
const allProxyRoutes = [...routeGroup.routes, ...openaiRouteGroup.routes];
|
|
@@ -861,6 +911,10 @@ async function createProxyStartApp(params) {
|
|
|
861
911
|
body = rawBody ? JSON.parse(rawBody) : emptyBody;
|
|
862
912
|
}
|
|
863
913
|
catch {
|
|
914
|
+
const metadata = requestMetadata.get(c.req.raw);
|
|
915
|
+
if (metadata) {
|
|
916
|
+
await recordRuntimeError(metadata, 400, "invalid_request_error", "Request body must be valid JSON");
|
|
917
|
+
}
|
|
864
918
|
return c.json({
|
|
865
919
|
type: "error",
|
|
866
920
|
error: {
|
|
@@ -878,9 +932,15 @@ async function createProxyStartApp(params) {
|
|
|
878
932
|
const toolCount = Array.isArray(bodyRec?.tools)
|
|
879
933
|
? bodyRec.tools.length
|
|
880
934
|
: 0;
|
|
935
|
+
const metadata = requestMetadata.get(c.req.raw);
|
|
936
|
+
if (metadata) {
|
|
937
|
+
metadata.model = String(model);
|
|
938
|
+
metadata.stream = stream === "stream";
|
|
939
|
+
metadata.toolCount = toolCount;
|
|
940
|
+
}
|
|
881
941
|
logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${model} ${stream} tools=${toolCount}`);
|
|
882
942
|
const ctx = {
|
|
883
|
-
requestId: crypto.randomUUID(),
|
|
943
|
+
requestId: metadata?.requestId ?? crypto.randomUUID(),
|
|
884
944
|
method: c.req.method,
|
|
885
945
|
path: c.req.path,
|
|
886
946
|
headers: Object.fromEntries(c.req.raw.headers.entries()),
|
|
@@ -973,6 +1033,7 @@ async function createProxyStartApp(params) {
|
|
|
973
1033
|
const { getStats } = await import("../../lib/proxy/usageStats.js");
|
|
974
1034
|
const { loadAccountCooldowns } = await import("../../lib/proxy/accountCooldown.js");
|
|
975
1035
|
const stats = getStats();
|
|
1036
|
+
const runtimeState = loadProxyState();
|
|
976
1037
|
const cooldowns = await loadAccountCooldowns();
|
|
977
1038
|
const storedAccountKeys = new Set();
|
|
978
1039
|
try {
|
|
@@ -1031,6 +1092,20 @@ async function createProxyStartApp(params) {
|
|
|
1031
1092
|
}),
|
|
1032
1093
|
primaryAccount,
|
|
1033
1094
|
},
|
|
1095
|
+
activity: (() => {
|
|
1096
|
+
const activity = getProxyActivitySnapshot();
|
|
1097
|
+
return {
|
|
1098
|
+
activeRequests: activity.activeRequests,
|
|
1099
|
+
lastActivityAt: activity.lastActivityAt?.toISOString() ?? null,
|
|
1100
|
+
};
|
|
1101
|
+
})(),
|
|
1102
|
+
autoUpdate: {
|
|
1103
|
+
enabled: isProxyAutoUpdateEnabled(),
|
|
1104
|
+
updaterPid: runtimeState?.updaterPid ?? null,
|
|
1105
|
+
updaterRunning: runtimeState?.updaterPid
|
|
1106
|
+
? isProcessRunning(runtimeState.updaterPid)
|
|
1107
|
+
: false,
|
|
1108
|
+
},
|
|
1034
1109
|
config: params.proxyConfig
|
|
1035
1110
|
? {
|
|
1036
1111
|
hasRouting: !!params.proxyConfig.routing,
|
|
@@ -1354,6 +1429,9 @@ async function startProxyRuntime(params) {
|
|
|
1354
1429
|
port: params.port,
|
|
1355
1430
|
});
|
|
1356
1431
|
markProxyReady(params.readiness);
|
|
1432
|
+
const updaterPid = managedByLaunchd && !params.argv.dev
|
|
1433
|
+
? spawnProxyUpdater(readinessHost, params.port, process.pid)
|
|
1434
|
+
: undefined;
|
|
1357
1435
|
const fallbackChain = params.proxyConfig?.routing?.fallbackChain?.map((entry) => ({
|
|
1358
1436
|
provider: entry.provider,
|
|
1359
1437
|
model: entry.model,
|
|
@@ -1376,6 +1454,7 @@ async function startProxyRuntime(params) {
|
|
|
1376
1454
|
? [...params.accountAllowlist]
|
|
1377
1455
|
: undefined,
|
|
1378
1456
|
guardPid,
|
|
1457
|
+
updaterPid,
|
|
1379
1458
|
managedBy: managedByLaunchd ? "launchd" : "manual",
|
|
1380
1459
|
passthrough: params.passthrough,
|
|
1381
1460
|
});
|
|
@@ -1643,6 +1722,9 @@ export const proxyStatusCommand = {
|
|
|
1643
1722
|
envFile: null,
|
|
1644
1723
|
fallbackChain: null,
|
|
1645
1724
|
accountAllowlist: null,
|
|
1725
|
+
autoUpdateEnabled: isProxyAutoUpdateEnabled(),
|
|
1726
|
+
updaterPid: null,
|
|
1727
|
+
updaterRunning: false,
|
|
1646
1728
|
};
|
|
1647
1729
|
if (state && isProcessRunning(state.pid)) {
|
|
1648
1730
|
status.running = true;
|
|
@@ -1657,6 +1739,10 @@ export const proxyStatusCommand = {
|
|
|
1657
1739
|
status.envFile = state.envFile ?? null;
|
|
1658
1740
|
status.fallbackChain = state.fallbackChain ?? null;
|
|
1659
1741
|
status.accountAllowlist = state.accountAllowlist ?? null;
|
|
1742
|
+
status.updaterPid = state.updaterPid ?? null;
|
|
1743
|
+
status.updaterRunning = state.updaterPid
|
|
1744
|
+
? isProcessRunning(state.updaterPid)
|
|
1745
|
+
: false;
|
|
1660
1746
|
}
|
|
1661
1747
|
// Fetch live stats before rendering (JSON or text)
|
|
1662
1748
|
let liveStats = null;
|
|
@@ -1689,6 +1775,7 @@ export const proxyStatusCommand = {
|
|
|
1689
1775
|
logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(status.mode ?? "full")}`);
|
|
1690
1776
|
logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`);
|
|
1691
1777
|
logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`);
|
|
1778
|
+
logger.always(` ${chalk.bold("Auto-update:")} ${status.autoUpdateEnabled ? chalk.green(status.updaterRunning ? `enabled (PID ${status.updaterPid})` : "enabled (worker unavailable)") : chalk.yellow("disabled")}`);
|
|
1692
1779
|
if (status.envFile) {
|
|
1693
1780
|
logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(status.envFile)}`);
|
|
1694
1781
|
}
|
|
@@ -1836,6 +1923,11 @@ export const proxyGuardCommand = {
|
|
|
1836
1923
|
type: "number",
|
|
1837
1924
|
alias: "pollIntervalMs",
|
|
1838
1925
|
default: 1_000,
|
|
1926
|
+
})
|
|
1927
|
+
.option("updater-only", {
|
|
1928
|
+
type: "boolean",
|
|
1929
|
+
alias: "updaterOnly",
|
|
1930
|
+
default: false,
|
|
1839
1931
|
})
|
|
1840
1932
|
.option("quiet", {
|
|
1841
1933
|
type: "boolean",
|
|
@@ -1852,11 +1944,12 @@ export const proxyGuardCommand = {
|
|
|
1852
1944
|
: 0;
|
|
1853
1945
|
const failureThreshold = Math.max(1, Number(argv.failureThreshold ?? 5));
|
|
1854
1946
|
const pollIntervalMs = Math.max(250, Number(argv.pollIntervalMs ?? 1_000));
|
|
1947
|
+
const updaterOnly = argv.updaterOnly === true;
|
|
1855
1948
|
if (!Number.isFinite(parentPid) || parentPid <= 0) {
|
|
1856
1949
|
return;
|
|
1857
1950
|
}
|
|
1858
|
-
//
|
|
1859
|
-
//
|
|
1951
|
+
// Package mutation belongs to the dedicated launchd updater worker. The
|
|
1952
|
+
// foreground fail-open guard remains cleanup-only.
|
|
1860
1953
|
const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
|
|
1861
1954
|
const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
|
|
1862
1955
|
const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
|
|
@@ -1874,7 +1967,8 @@ export const proxyGuardCommand = {
|
|
|
1874
1967
|
}
|
|
1875
1968
|
// Auto-update only works on macOS with launchd. On other platforms,
|
|
1876
1969
|
// there's no restart mechanism, so skip the update loop entirely.
|
|
1877
|
-
const canAutoUpdate =
|
|
1970
|
+
const canAutoUpdate = updaterOnly &&
|
|
1971
|
+
isProxyAutoUpdateEnabled() &&
|
|
1878
1972
|
process.platform === "darwin" &&
|
|
1879
1973
|
(await isLaunchdManaging());
|
|
1880
1974
|
let guardStopping = false;
|
|
@@ -1901,7 +1995,6 @@ export const proxyGuardCommand = {
|
|
|
1901
1995
|
try {
|
|
1902
1996
|
// Lazy-load update modules so they're only imported at check time
|
|
1903
1997
|
const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
|
|
1904
|
-
const { checkTrafficQuiet } = await import("../../lib/proxy/quietDetector.js");
|
|
1905
1998
|
const { recordCheck, isVersionSuppressed, suppressVersion, recordSuccessfulUpdate, } = await import("../../lib/proxy/updateState.js");
|
|
1906
1999
|
// 1. Check for update
|
|
1907
2000
|
const result = await checkForUpdate(runningVersion);
|
|
@@ -1913,141 +2006,111 @@ export const proxyGuardCommand = {
|
|
|
1913
2006
|
logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
|
|
1914
2007
|
return;
|
|
1915
2008
|
}
|
|
1916
|
-
logger.always(`[
|
|
1917
|
-
// 2.
|
|
1918
|
-
//
|
|
1919
|
-
// restart causes a ~1-3s blip. Blocking updates for hours/days because
|
|
1920
|
-
// traffic never goes silent is worse than a brief interruption.
|
|
1921
|
-
const maxQuietWaitMs = 5 * 60 * 1000; // 5 minutes max, then proceed
|
|
2009
|
+
logger.always(`[updater] update available: ${runningVersion} → ${result.latestVersion}`);
|
|
2010
|
+
// 2. Wait for an exact runtime-idle window. Never force an update while
|
|
2011
|
+
// a request or stream is active; the next worker cycle can try again.
|
|
1922
2012
|
const quietPollMs = 10_000; // check every 10s
|
|
1923
|
-
|
|
1924
|
-
while (Date.now() - quietStart < maxQuietWaitMs) {
|
|
2013
|
+
while (!guardStopping) {
|
|
1925
2014
|
if (getProcessStatus(parentPid) === "not_running") {
|
|
1926
|
-
logger.always(`[
|
|
2015
|
+
logger.always(`[updater] parent process died while waiting for idle traffic`);
|
|
1927
2016
|
return;
|
|
1928
2017
|
}
|
|
1929
|
-
const
|
|
1930
|
-
if (
|
|
1931
|
-
logger.always(`[
|
|
2018
|
+
const activity = await getProxyRuntimeActivity(host, port);
|
|
2019
|
+
if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
|
|
2020
|
+
logger.always(`[updater] traffic idle, proceeding with update`);
|
|
1932
2021
|
break;
|
|
1933
2022
|
}
|
|
1934
|
-
logger.debug(`[
|
|
2023
|
+
logger.debug(`[updater] waiting for idle traffic (${activity?.activeRequests ?? "unknown"} active requests)`);
|
|
1935
2024
|
await new Promise((r) => setTimeout(r, quietPollMs));
|
|
1936
2025
|
}
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
// Only the restart afterwards causes a brief interruption.
|
|
1940
|
-
const finalQuiet = checkTrafficQuiet(QUIET_THRESHOLD_MS);
|
|
1941
|
-
if (!finalQuiet.isQuiet) {
|
|
1942
|
-
logger.always(`[guard] traffic still active after ${Math.round(maxQuietWaitMs / 1000)}s wait, proceeding with update anyway (restart will briefly interrupt in-flight requests)`);
|
|
2026
|
+
if (guardStopping) {
|
|
2027
|
+
return;
|
|
1943
2028
|
}
|
|
1944
2029
|
// 3. Install update (validate version string before passing to shell)
|
|
1945
2030
|
if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
|
|
1946
2031
|
logger.always(`[guard] WARNING: invalid version format "${result.latestVersion}", skipping`);
|
|
1947
2032
|
return;
|
|
1948
2033
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
logger.always(`[guard] pnpm candidates: ${pnpmResolution.tried
|
|
1955
|
-
.map((c) => `${c.path}(${c.working ? `v${c.version}` : "BROKEN"})`)
|
|
2034
|
+
const installerResolution = resolveGlobalInstaller({
|
|
2035
|
+
entryScript: process.argv[1],
|
|
2036
|
+
});
|
|
2037
|
+
logger.always(`[updater] package-manager candidates: ${installerResolution.tried
|
|
2038
|
+
.map((candidate) => `${candidate.kind}:${candidate.bin}(${candidate.installable ? `v${candidate.version}` : (candidate.reason ?? "unusable")})`)
|
|
1956
2039
|
.join(", ")}`);
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
// once the user fixes pnpm).
|
|
1961
|
-
logger.always(`[guard] WARNING: no working pnpm found; skipping update cycle. Install pnpm or set NEUROLINK_PNPM_PATH.`);
|
|
2040
|
+
const installer = installerResolution.installer;
|
|
2041
|
+
if (!installer) {
|
|
2042
|
+
logger.always(`[updater] WARNING: no package manager has a writable global root and executable directory; skipping this cycle`);
|
|
1962
2043
|
return;
|
|
1963
2044
|
}
|
|
1964
|
-
logger.always(`[
|
|
2045
|
+
logger.always(`[updater] installing @juspay/neurolink@${result.latestVersion} via ${installer.kind} ${installer.bin} (v${installer.version})`);
|
|
1965
2046
|
if (guardStopping || getProcessStatus(parentPid) === "not_running") {
|
|
1966
2047
|
return;
|
|
1967
2048
|
}
|
|
1968
2049
|
const { execFileSync } = await import("node:child_process");
|
|
1969
2050
|
try {
|
|
1970
|
-
execFileSync(
|
|
2051
|
+
execFileSync(installer.bin, getGlobalInstallArgs(installer.kind, `@juspay/neurolink@${result.latestVersion}`), {
|
|
1971
2052
|
timeout: 120_000,
|
|
1972
2053
|
stdio: "pipe",
|
|
1973
2054
|
});
|
|
1974
2055
|
}
|
|
1975
2056
|
catch (installErr) {
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
typeof installErr === "object" &&
|
|
1979
|
-
"stderr" in installErr
|
|
1980
|
-
? String(installErr.stderr).slice(0, 500)
|
|
1981
|
-
: "";
|
|
1982
|
-
const msg = installErr instanceof Error
|
|
1983
|
-
? installErr.message
|
|
1984
|
-
: String(installErr);
|
|
1985
|
-
logger.always(`[guard] WARNING: pnpm install failed: ${msg}${stderr ? `\n stderr: ${stderr}` : ""}`);
|
|
1986
|
-
suppressVersion(result.latestVersion, `install_failed: ${msg.slice(0, 200)}${stderr ? ` | stderr: ${stderr.slice(0, 200)}` : ""}`);
|
|
2057
|
+
const detail = describeInstallFailure(installErr);
|
|
2058
|
+
logger.always(`[updater] WARNING: global install failed:\n${detail}`);
|
|
1987
2059
|
return;
|
|
1988
2060
|
}
|
|
1989
|
-
// 4.
|
|
1990
|
-
//
|
|
2061
|
+
// 4. Refresh and validate the stable trampoline. The plist already
|
|
2062
|
+
// points at this path, so it must not be unloaded or rewritten here.
|
|
1991
2063
|
try {
|
|
1992
|
-
const { writeFileSync, existsSync: fsExists, mkdirSync: fsMkdir, } = await import("fs");
|
|
1993
|
-
if (!fsExists(PLIST_DIR)) {
|
|
1994
|
-
fsMkdir(PLIST_DIR, { recursive: true });
|
|
1995
|
-
}
|
|
1996
|
-
// Rewrite the trampoline and plist so the restarted service
|
|
1997
|
-
// resolves the newly installed binary via PATH.
|
|
1998
2064
|
writeTrampoline();
|
|
1999
2065
|
// Validate the trampoline actually resolves to the NEW version
|
|
2000
2066
|
// before asking launchd to restart. If the install somehow left
|
|
2001
2067
|
// PATH still pointing at the old version, don't kickstart.
|
|
2002
2068
|
const probed = probeBinVersion(TRAMPOLINE_PATH);
|
|
2003
2069
|
if (!probed) {
|
|
2004
|
-
logger.always(`[
|
|
2070
|
+
logger.always(`[updater] WARNING: trampoline does not resolve to a working neurolink after install; skipping restart`);
|
|
2005
2071
|
suppressVersion(result.latestVersion, `trampoline_broken_after_install: ${TRAMPOLINE_PATH} --version failed`);
|
|
2006
2072
|
return;
|
|
2007
2073
|
}
|
|
2008
2074
|
if (probed !== result.latestVersion) {
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
// shadows with an older shim. Restarting would run the wrong
|
|
2013
|
-
// version — abort.
|
|
2014
|
-
logger.always(`[guard] ABORT: trampoline resolves to v${probed} but installed v${result.latestVersion}.`);
|
|
2015
|
-
logger.always(`[guard] pnpm used: ${pnpmResolution.bin} (v${pnpmResolution.version})`);
|
|
2016
|
-
logger.always(`[guard] This usually means pnpm's global store doesn't match the PATH-visible neurolink.`);
|
|
2017
|
-
logger.always(`[guard] Fix: run 'pnpm add -g @juspay/neurolink' with the SAME pnpm whose bin dir is on PATH.`);
|
|
2018
|
-
suppressVersion(result.latestVersion, `version_mismatch: trampoline=${probed} expected=${result.latestVersion} pnpm=${pnpmResolution.bin}(v${pnpmResolution.version})`);
|
|
2075
|
+
logger.always(`[updater] ABORT: trampoline resolves to v${probed} but installed v${result.latestVersion}`);
|
|
2076
|
+
logger.always(`[updater] installer used: ${installer.kind} ${installer.bin} (v${installer.version})`);
|
|
2077
|
+
suppressVersion(result.latestVersion, `version_mismatch: trampoline=${probed} expected=${result.latestVersion} installer=${installer.kind}:${installer.bin}(v${installer.version})`);
|
|
2019
2078
|
return;
|
|
2020
2079
|
}
|
|
2021
|
-
|
|
2022
|
-
const updatedPlist = buildPlist(port, host, existingArgs.envFile, existingArgs.configFile);
|
|
2023
|
-
writeFileSync(PLIST_PATH, updatedPlist, "utf-8");
|
|
2024
|
-
logger.always(`[guard] trampoline (resolves to v${probed}) + plist rewritten at ${PLIST_PATH}`);
|
|
2080
|
+
logger.always(`[updater] trampoline validated at v${probed}`);
|
|
2025
2081
|
}
|
|
2026
|
-
catch (
|
|
2027
|
-
logger.always(`[
|
|
2028
|
-
|
|
2082
|
+
catch (trampolineError) {
|
|
2083
|
+
logger.always(`[updater] WARNING: failed to refresh trampoline; refusing restart: ${trampolineError instanceof Error
|
|
2084
|
+
? trampolineError.message
|
|
2085
|
+
: String(trampolineError)}`);
|
|
2086
|
+
return;
|
|
2029
2087
|
}
|
|
2030
2088
|
if (guardStopping || getProcessStatus(parentPid) === "not_running") {
|
|
2031
2089
|
return;
|
|
2032
2090
|
}
|
|
2091
|
+
// Installation can overlap new traffic. Re-establish a full idle window
|
|
2092
|
+
// before restart so no accepted request or long stream is interrupted.
|
|
2093
|
+
while (!guardStopping) {
|
|
2094
|
+
if (getProcessStatus(parentPid) === "not_running") {
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
const activity = await getProxyRuntimeActivity(host, port);
|
|
2098
|
+
if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
|
|
2099
|
+
break;
|
|
2100
|
+
}
|
|
2101
|
+
logger.debug(`[updater] update installed; deferring restart (${activity?.activeRequests ?? "unknown"} active requests)`);
|
|
2102
|
+
await sleep(10_000);
|
|
2103
|
+
}
|
|
2104
|
+
if (guardStopping) {
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2033
2107
|
// Signal the health loop to not exit when it detects
|
|
2034
2108
|
// the parent PID is gone — we're intentionally restarting.
|
|
2035
2109
|
updateRestartInProgress = true;
|
|
2036
|
-
logger.always(`[
|
|
2110
|
+
logger.always(`[updater] restarting proxy via launchctl kickstart`);
|
|
2037
2111
|
const uid = process.getuid?.() ?? 501;
|
|
2038
2112
|
try {
|
|
2039
|
-
|
|
2040
|
-
// because a forced kickstart reuses the cached plist and ignores any
|
|
2041
|
-
// on-disk changes (like the trampoline rewrite above).
|
|
2042
|
-
try {
|
|
2043
|
-
execFileSync("launchctl", ["bootout", `gui/${uid}/${PLIST_LABEL}`], { timeout: 10_000, stdio: "pipe" });
|
|
2044
|
-
}
|
|
2045
|
-
catch {
|
|
2046
|
-
// Job may not be loaded (first install, or already unloaded)
|
|
2047
|
-
}
|
|
2048
|
-
// bootstrap loads the plist fresh from disk, picking up the
|
|
2049
|
-
// new trampoline-based ProgramArguments.
|
|
2050
|
-
execFileSync("launchctl", ["bootstrap", `gui/${uid}`, PLIST_PATH], {
|
|
2113
|
+
execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
|
|
2051
2114
|
timeout: 10_000,
|
|
2052
2115
|
stdio: "pipe",
|
|
2053
2116
|
});
|
|
@@ -2057,8 +2120,7 @@ export const proxyGuardCommand = {
|
|
|
2057
2120
|
const msg = restartErr instanceof Error
|
|
2058
2121
|
? restartErr.message
|
|
2059
2122
|
: String(restartErr);
|
|
2060
|
-
logger.always(`[
|
|
2061
|
-
suppressVersion(result.latestVersion, `restart_failed: ${msg.slice(0, 200)}`);
|
|
2123
|
+
logger.always(`[updater] WARNING: launchctl kickstart failed: ${msg}`);
|
|
2062
2124
|
return;
|
|
2063
2125
|
}
|
|
2064
2126
|
// 5. Wait for healthy restart
|
|
@@ -2083,19 +2145,19 @@ export const proxyGuardCommand = {
|
|
|
2083
2145
|
}
|
|
2084
2146
|
}
|
|
2085
2147
|
if (healthy) {
|
|
2086
|
-
logger.always(`[
|
|
2148
|
+
logger.always(`[updater] update successful: now running ${result.latestVersion}`);
|
|
2087
2149
|
recordSuccessfulUpdate(result.latestVersion);
|
|
2088
|
-
// The
|
|
2150
|
+
// The replacement proxy starts a worker running the new version.
|
|
2089
2151
|
process.exit(0);
|
|
2090
2152
|
}
|
|
2091
2153
|
else {
|
|
2092
|
-
logger.always(`[
|
|
2154
|
+
logger.always(`[updater] WARNING: proxy unhealthy after update to ${result.latestVersion}`);
|
|
2093
2155
|
suppressVersion(result.latestVersion, "unhealthy_after_restart");
|
|
2094
2156
|
updateRestartInProgress = false;
|
|
2095
2157
|
}
|
|
2096
2158
|
}
|
|
2097
2159
|
catch (err) {
|
|
2098
|
-
logger.always(`[
|
|
2160
|
+
logger.always(`[updater] update check error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2099
2161
|
}
|
|
2100
2162
|
finally {
|
|
2101
2163
|
updateInProgress = false;
|
|
@@ -2127,7 +2189,9 @@ export const proxyGuardCommand = {
|
|
|
2127
2189
|
}
|
|
2128
2190
|
break;
|
|
2129
2191
|
}
|
|
2130
|
-
if (!
|
|
2192
|
+
if (!updateRestartInProgress &&
|
|
2193
|
+
!healthy &&
|
|
2194
|
+
consecutiveUnhealthy >= failureThreshold) {
|
|
2131
2195
|
// A detached guard cannot safely decide that a live process should be
|
|
2132
2196
|
// replaced. Leave recovery to the foreground operator or launchd.
|
|
2133
2197
|
if (!argv.quiet) {
|
|
@@ -2144,6 +2208,9 @@ export const proxyGuardCommand = {
|
|
|
2144
2208
|
parentStatus = getProcessStatus(parentPid);
|
|
2145
2209
|
}
|
|
2146
2210
|
stopUpdateChecks();
|
|
2211
|
+
if (updaterOnly) {
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2147
2214
|
const guardHost = host === "0.0.0.0" ? "localhost" : host;
|
|
2148
2215
|
const expectedBaseUrl = `http://${guardHost}:${port}`;
|
|
2149
2216
|
// The parent is confirmed gone and no replacement is healthy. Foreground
|
|
@@ -2335,36 +2402,6 @@ function buildLaunchdPath() {
|
|
|
2335
2402
|
}
|
|
2336
2403
|
return [...segments].join(":");
|
|
2337
2404
|
}
|
|
2338
|
-
/**
|
|
2339
|
-
* Parse the existing launchd plist to extract --env-file and --config values.
|
|
2340
|
-
* Used by the auto-updater to rewrite the plist with the same configuration.
|
|
2341
|
-
*/
|
|
2342
|
-
function parseExistingPlistArgs() {
|
|
2343
|
-
try {
|
|
2344
|
-
const { readFileSync, existsSync: fsExists } = _require("fs");
|
|
2345
|
-
if (!fsExists(PLIST_PATH)) {
|
|
2346
|
-
return {};
|
|
2347
|
-
}
|
|
2348
|
-
const xml = readFileSync(PLIST_PATH, "utf-8");
|
|
2349
|
-
// Extract --env-file value: <string>--env-file</string>\n <string>VALUE</string>
|
|
2350
|
-
const envMatch = xml.match(/<string>--env-file<\/string>\s*<string>([^<]+)<\/string>/);
|
|
2351
|
-
const configMatch = xml.match(/<string>--config<\/string>\s*<string>([^<]+)<\/string>/);
|
|
2352
|
-
// Unescape XML entities so buildPlist() doesn't double-escape them.
|
|
2353
|
-
const unescapeXml = (value) => value
|
|
2354
|
-
?.replace(/'/g, "'")
|
|
2355
|
-
.replace(/"/g, '"')
|
|
2356
|
-
.replace(/>/g, ">")
|
|
2357
|
-
.replace(/</g, "<")
|
|
2358
|
-
.replace(/&/g, "&");
|
|
2359
|
-
return {
|
|
2360
|
-
envFile: unescapeXml(envMatch?.[1]),
|
|
2361
|
-
configFile: unescapeXml(configMatch?.[1]),
|
|
2362
|
-
};
|
|
2363
|
-
}
|
|
2364
|
-
catch {
|
|
2365
|
-
return {};
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
2405
|
function buildPlist(port, host, envFile, configFile) {
|
|
2369
2406
|
// The plist invokes the trampoline script (a tiny shell wrapper at
|
|
2370
2407
|
// ~/.neurolink/bin/neurolink-proxy) which re-resolves the real
|