包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。 排除 node_modules、npm 缓存、sessions 等运行时数据。
425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
// npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes
|
|
import { existsSync, readFileSync, realpathSync, readdirSync, statSync, writeFileSync, renameSync, mkdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
import { join, dirname, extname, resolve, sep } from "node:path";
|
|
import { getAgentPath } from "./agent-dir.ts";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
|
|
const CACHE_VERSION = 1;
|
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
interface NpxCacheEntry {
|
|
resolvedBin: string;
|
|
resolvedAt: number;
|
|
packageVersion?: string;
|
|
isJs: boolean;
|
|
}
|
|
|
|
interface NpxCache {
|
|
version: number;
|
|
entries: Record<string, NpxCacheEntry>;
|
|
}
|
|
|
|
export interface NpxResolution {
|
|
binPath: string;
|
|
extraArgs: string[];
|
|
isJs: boolean;
|
|
}
|
|
|
|
interface ParsedInvocation {
|
|
packageSpec: string;
|
|
binName?: string;
|
|
extraArgs: string[];
|
|
}
|
|
|
|
export async function resolveNpxBinary(
|
|
command: string,
|
|
args: string[]
|
|
): Promise<NpxResolution | null> {
|
|
const parsed = command === "npx"
|
|
? parseNpxArgs(args)
|
|
: command === "npm"
|
|
? parseNpmExecArgs(args)
|
|
: null;
|
|
|
|
if (!parsed) return null;
|
|
|
|
const cacheKey = JSON.stringify([command, ...args]);
|
|
const cache = loadCache();
|
|
const cached = cache?.entries?.[cacheKey];
|
|
|
|
if (cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS && existsSync(cached.resolvedBin)) {
|
|
return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs };
|
|
}
|
|
|
|
const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
|
|
if (resolved) {
|
|
saveCacheEntry(cacheKey, resolved);
|
|
return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs };
|
|
}
|
|
|
|
// Slow path: force npx cache population
|
|
await forceNpxCache(parsed.packageSpec);
|
|
const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName);
|
|
if (resolvedAfterInstall) {
|
|
saveCacheEntry(cacheKey, resolvedAfterInstall);
|
|
return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseNpxArgs(args: string[]): ParsedInvocation | null {
|
|
const separatorIndex = args.indexOf("--");
|
|
const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args;
|
|
const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [];
|
|
|
|
const positionals: string[] = [];
|
|
let packageSpec: string | undefined;
|
|
let sawPackageFlag = false;
|
|
let foundFirstPositional = false;
|
|
|
|
for (let i = 0; i < before.length; i++) {
|
|
const arg = before[i];
|
|
if (foundFirstPositional) {
|
|
positionals.push(arg);
|
|
continue;
|
|
}
|
|
if (arg === "-y" || arg === "--yes") continue;
|
|
if (arg === "-p" || arg === "--package") {
|
|
const value = before[i + 1];
|
|
if (!value || value.startsWith("-")) return null;
|
|
if (!packageSpec) packageSpec = value;
|
|
sawPackageFlag = true;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--package=")) {
|
|
const value = arg.slice("--package=".length);
|
|
if (!value) return null;
|
|
if (!packageSpec) packageSpec = value;
|
|
sawPackageFlag = true;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
return null;
|
|
}
|
|
positionals.push(arg);
|
|
foundFirstPositional = true;
|
|
}
|
|
|
|
if (sawPackageFlag) {
|
|
const binName = positionals[0];
|
|
if (!packageSpec || !binName) return null;
|
|
const extraArgs = positionals.slice(1).concat(after);
|
|
return { packageSpec, binName, extraArgs };
|
|
}
|
|
|
|
const packagePositional = positionals[0];
|
|
if (!packagePositional) return null;
|
|
const extraArgs = positionals.slice(1).concat(after);
|
|
return { packageSpec: packagePositional, extraArgs };
|
|
}
|
|
|
|
function parseNpmExecArgs(args: string[]): ParsedInvocation | null {
|
|
if (args[0] !== "exec") return null;
|
|
const execArgs = args.slice(1);
|
|
const separatorIndex = execArgs.indexOf("--");
|
|
if (separatorIndex < 0) return null;
|
|
|
|
const before = execArgs.slice(0, separatorIndex);
|
|
const after = execArgs.slice(separatorIndex + 1);
|
|
|
|
let packageSpec: string | undefined;
|
|
for (let i = 0; i < before.length; i++) {
|
|
const arg = before[i];
|
|
if (arg === "-y" || arg === "--yes") continue;
|
|
if (arg === "--package") {
|
|
const value = before[i + 1];
|
|
if (!value || value.startsWith("-")) return null;
|
|
if (!packageSpec) packageSpec = value;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--package=")) {
|
|
const value = arg.slice("--package=".length);
|
|
if (!value) return null;
|
|
if (!packageSpec) packageSpec = value;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("-")) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const binName = after[0];
|
|
if (!packageSpec || !binName) return null;
|
|
const extraArgs = after.slice(1);
|
|
return { packageSpec, binName, extraArgs };
|
|
}
|
|
|
|
function resolveFromNpmCache(packageSpec: string, binName?: string): NpxCacheEntry | null {
|
|
const cacheDir = getNpmCacheDir();
|
|
if (!cacheDir) return null;
|
|
|
|
const packageName = extractPackageName(packageSpec);
|
|
if (!packageName) return null;
|
|
|
|
const packageDir = findCachedPackageDir(cacheDir, packageName);
|
|
if (!packageDir) return null;
|
|
|
|
const packageJsonPath = join(packageDir, "package.json");
|
|
if (!existsSync(packageJsonPath)) return null;
|
|
|
|
let pkg: { bin?: string | Record<string, string>; version?: string } | null = null;
|
|
try {
|
|
pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
|
|
bin?: string | Record<string, string>;
|
|
version?: string;
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const binField = pkg?.bin;
|
|
if (!binField) return null;
|
|
|
|
const candidates = buildBinCandidates(packageName, binName);
|
|
let chosenBinName: string | undefined;
|
|
let binRel: string | undefined;
|
|
|
|
if (typeof binField === "string") {
|
|
chosenBinName = defaultBinName(packageName);
|
|
binRel = binField;
|
|
} else {
|
|
for (const candidate of candidates) {
|
|
if (binField[candidate]) {
|
|
chosenBinName = candidate;
|
|
binRel = binField[candidate];
|
|
break;
|
|
}
|
|
}
|
|
if (!binRel) {
|
|
const firstEntry = Object.entries(binField)[0];
|
|
if (firstEntry) {
|
|
chosenBinName = firstEntry[0];
|
|
binRel = firstEntry[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!binRel) return null;
|
|
|
|
const nodeModulesDir = findNodeModulesDir(packageDir);
|
|
const binLink = chosenBinName ? join(nodeModulesDir, ".bin", chosenBinName) : null;
|
|
let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : "";
|
|
if (!resolvedBin) {
|
|
resolvedBin = resolve(packageDir, binRel);
|
|
if (!existsSync(resolvedBin)) return null;
|
|
}
|
|
|
|
const isJs = detectJsBinary(resolvedBin);
|
|
return {
|
|
resolvedBin,
|
|
resolvedAt: Date.now(),
|
|
packageVersion: pkg?.version,
|
|
isJs,
|
|
};
|
|
}
|
|
|
|
const FORCE_CACHE_TIMEOUT_MS = 30_000;
|
|
|
|
async function forceNpxCache(packageSpec: string): Promise<void> {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const proc = spawn(
|
|
"npm",
|
|
["exec", "--yes", "--package", packageSpec, "--", "node", "-e", "1"],
|
|
{ stdio: "ignore" }
|
|
);
|
|
const timer = setTimeout(() => {
|
|
proc.kill();
|
|
reject(new Error("timeout"));
|
|
}, FORCE_CACHE_TIMEOUT_MS);
|
|
timer.unref();
|
|
proc.on("close", () => { clearTimeout(timer); resolve(); });
|
|
proc.on("error", (err) => { clearTimeout(timer); reject(err); });
|
|
});
|
|
} catch {
|
|
// Ignore failures, resolution will fall back to original command
|
|
}
|
|
}
|
|
|
|
function buildBinCandidates(packageName: string, explicitBin?: string): string[] {
|
|
const candidates: string[] = [];
|
|
if (explicitBin) candidates.push(explicitBin);
|
|
|
|
if (packageName.startsWith("@")) {
|
|
const namePart = packageName.split("/")[1] ?? "";
|
|
const scopePart = packageName.split("/")[0]?.replace("@", "") ?? "";
|
|
if (namePart) candidates.push(namePart);
|
|
if (scopePart && namePart) candidates.push(`${scopePart}-${namePart}`);
|
|
} else {
|
|
candidates.push(packageName);
|
|
}
|
|
|
|
return [...new Set(candidates.filter(Boolean))];
|
|
}
|
|
|
|
function extractPackageName(spec: string): string | null {
|
|
const trimmed = spec.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith("@")) {
|
|
const slashIndex = trimmed.indexOf("/");
|
|
if (slashIndex < 0) return null;
|
|
const atIndex = trimmed.lastIndexOf("@");
|
|
if (atIndex > slashIndex) {
|
|
return trimmed.slice(0, atIndex);
|
|
}
|
|
return trimmed;
|
|
}
|
|
const atIndex = trimmed.indexOf("@");
|
|
return atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed;
|
|
}
|
|
|
|
function defaultBinName(packageName: string): string {
|
|
if (packageName.startsWith("@")) {
|
|
const parts = packageName.split("/");
|
|
return parts[1] ?? packageName.replace("@", "").replace("/", "-");
|
|
}
|
|
return packageName;
|
|
}
|
|
|
|
function findCachedPackageDir(cacheDir: string, packageName: string): string | null {
|
|
const npxDir = join(cacheDir, "_npx");
|
|
if (!existsSync(npxDir)) return null;
|
|
|
|
const packagePathParts = packageName.startsWith("@")
|
|
? packageName.split("/")
|
|
: [packageName];
|
|
|
|
const candidates = readdirSync(npxDir, { withFileTypes: true })
|
|
.filter(entry => entry.isDirectory())
|
|
.map(entry => {
|
|
const full = join(npxDir, entry.name);
|
|
const mtime = safeStatMtime(full);
|
|
return { name: entry.name, mtime };
|
|
})
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
for (const entry of candidates) {
|
|
const pkgDir = join(npxDir, entry.name, "node_modules", ...packagePathParts);
|
|
if (existsSync(join(pkgDir, "package.json"))) {
|
|
return pkgDir;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findNodeModulesDir(packageDir: string): string {
|
|
const parts = packageDir.split(sep);
|
|
const idx = parts.lastIndexOf("node_modules");
|
|
if (idx >= 0) {
|
|
return parts.slice(0, idx + 1).join(sep);
|
|
}
|
|
return join(packageDir, "..");
|
|
}
|
|
|
|
function detectJsBinary(binPath: string): boolean {
|
|
const ext = extname(binPath).toLowerCase();
|
|
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return true;
|
|
try {
|
|
const fd = openSync(binPath, "r");
|
|
try {
|
|
const buf = Buffer.alloc(256);
|
|
readSync(fd, buf, 0, 256, 0);
|
|
const firstLine = buf.toString("utf-8").split("\n")[0] ?? "";
|
|
return firstLine.startsWith("#!") && firstLine.includes("node");
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
let npmCacheDirCached: string | null | undefined;
|
|
|
|
function getNpmCacheDir(): string | null {
|
|
if (npmCacheDirCached !== undefined) return npmCacheDirCached;
|
|
if (process.env.NPM_CONFIG_CACHE) {
|
|
npmCacheDirCached = process.env.NPM_CONFIG_CACHE;
|
|
return npmCacheDirCached;
|
|
}
|
|
try {
|
|
const result = spawnSync("npm", ["config", "get", "cache"], { encoding: "utf-8" });
|
|
if (result.status === 0) {
|
|
const path = String(result.stdout).trim();
|
|
npmCacheDirCached = path || null;
|
|
return npmCacheDirCached;
|
|
}
|
|
} catch {
|
|
npmCacheDirCached = null;
|
|
return null;
|
|
}
|
|
npmCacheDirCached = null;
|
|
return null;
|
|
}
|
|
|
|
function getNpxCachePath(): string {
|
|
return getAgentPath("mcp-npx-cache.json");
|
|
}
|
|
|
|
function loadCache(): NpxCache | null {
|
|
const cachePath = getNpxCachePath();
|
|
if (!existsSync(cachePath)) return null;
|
|
try {
|
|
const raw = JSON.parse(readFileSync(cachePath, "utf-8"));
|
|
if (!raw || typeof raw !== "object") return null;
|
|
if (raw.version !== CACHE_VERSION) return null;
|
|
if (!raw.entries || typeof raw.entries !== "object") return null;
|
|
return raw as NpxCache;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveCacheEntry(key: string, entry: NpxCacheEntry): void {
|
|
const cachePath = getNpxCachePath();
|
|
const dir = dirname(cachePath);
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
let merged: NpxCache = { version: CACHE_VERSION, entries: {} };
|
|
try {
|
|
if (existsSync(cachePath)) {
|
|
const existing = JSON.parse(readFileSync(cachePath, "utf-8")) as NpxCache;
|
|
if (existing && existing.version === CACHE_VERSION && existing.entries) {
|
|
merged.entries = { ...existing.entries };
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore parse errors
|
|
}
|
|
|
|
merged.entries[key] = entry;
|
|
const tmpPath = `${cachePath}.${process.pid}.tmp`;
|
|
writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
renameSync(tmpPath, cachePath);
|
|
}
|
|
|
|
function safeRealpath(path: string): string {
|
|
try {
|
|
return realpathSync(path);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function safeStatMtime(path: string): number {
|
|
try {
|
|
return statSync(path).mtimeMs;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|