import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises" import { basename, dirname, join, sep } from "node:path" import type { InstalledPlugin, RunCommand } from "./types" export async function installCachedPlugin(input: { readonly codexHome: string readonly marketplaceName: string readonly name: string readonly sourcePath: string readonly version: string readonly runCommand: RunCommand }): Promise { await maybeRunNpmInstall(input.sourcePath, input.runCommand) await maybeRunNpmBuild(input.sourcePath, input.runCommand) const targetPath = join(input.codexHome, "plugins", "cache", input.marketplaceName, input.name, input.version) await replaceDirectory(input.sourcePath, targetPath) await maybeRunNpmInstall(targetPath, input.runCommand, ["install", "--omit=dev"]) await rewriteCachedMcpManifest(targetPath) return { name: input.name, version: input.version, path: targetPath } } export async function pruneMarketplaceCache(input: { readonly codexHome: string readonly marketplaceName: string readonly keepPluginNames: readonly string[] }): Promise { const cacheRoot = join(input.codexHome, "plugins", "cache", input.marketplaceName) if (!(await exists(cacheRoot))) return const keep = new Set(input.keepPluginNames) const entries = await readdir(cacheRoot, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory() || keep.has(entry.name)) continue await rm(join(cacheRoot, entry.name), { recursive: true, force: true }) } } export async function linkCachedPluginBins(input: { readonly binDir: string readonly pluginRoot: string }): Promise { const binLinks = await discoverPackageBins(input.pluginRoot) await mkdir(input.binDir, { recursive: true }) const linked: Array<{ name: string; path: string; target: string }> = [] for (const link of binLinks) { const linkPath = join(input.binDir, link.name) await replaceSymlink(linkPath, link.target) linked.push({ name: link.name, path: linkPath, target: link.target }) } return linked } export async function rewriteCachedMcpManifest(pluginRoot: string): Promise { const manifestPath = join(pluginRoot, ".mcp.json") if (!(await exists(manifestPath))) return const raw = await readFile(manifestPath, "utf8") const parsed: unknown = JSON.parse(raw) if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return let changed = false for (const server of Object.values(parsed.mcpServers)) { if (!isRecord(server)) continue if (server.cwd === "." || server.cwd === "./") { delete server.cwd changed = true } const currentArgs = server.args if (!Array.isArray(currentArgs)) continue const nextArgs = currentArgs.map((arg) => { if (typeof arg !== "string") return arg if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg) return arg }) if (nextArgs.some((value, index) => value !== currentArgs[index])) { server.args = nextArgs changed = true } } if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`) } async function maybeRunNpmInstall(cwd: string, runCommand: RunCommand, args: readonly string[] = ["install"]): Promise { if (!(await exists(join(cwd, "package.json")))) return await runCommand("npm", args, { cwd }) } async function maybeRunNpmBuild(cwd: string, runCommand: RunCommand): Promise { if (!(await exists(join(cwd, "package.json")))) return const packageJson: unknown = JSON.parse(await readFile(join(cwd, "package.json"), "utf8")) if (!isRecord(packageJson)) return const scripts = packageJson.scripts if (!isRecord(scripts) || typeof scripts.build !== "string") return await runCommand("npm", ["run", "build"], { cwd }) } async function replaceDirectory(sourcePath: string, targetPath: string): Promise { await mkdir(dirname(targetPath), { recursive: true }) const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`) await rm(tempPath, { recursive: true, force: true }) await cp(sourcePath, tempPath, { recursive: true, filter: (source) => shouldCopyPluginPath(source, sourcePath) }) await rm(targetPath, { recursive: true, force: true }) await rename(tempPath, targetPath) } async function discoverPackageBins(root: string): Promise { const links: Array<{ name: string; target: string }> = [] await collectPackageBins(root, root, links) return links } async function collectPackageBins(directory: string, root: string, links: Array<{ name: string; target: string }>): Promise { const entries = await readdir(directory, { withFileTypes: true }) if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) { await appendPackageBinLinks(join(directory, "package.json"), directory, links) } for (const entry of entries) { if (!entry.isDirectory()) continue if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue const childPath = join(directory, entry.name) if (!childPath.startsWith(root)) continue await collectPackageBins(childPath, root, links) } } async function appendPackageBinLinks(packageJsonPath: string, packageRoot: string, links: Array<{ name: string; target: string }>): Promise { const packageJson: unknown = JSON.parse(await readFile(packageJsonPath, "utf8")) if (!isRecord(packageJson)) return const packageName = packageJson.name const packageBin = packageJson.bin if (typeof packageBin === "string" && typeof packageName === "string") { links.push({ name: basename(packageName), target: join(packageRoot, packageBin) }) return } if (!isRecord(packageBin)) return for (const [name, target] of Object.entries(packageBin)) { if (typeof target !== "string") continue links.push({ name, target: join(packageRoot, target) }) } } async function replaceSymlink(linkPath: string, targetPath: string): Promise { if (await existingNonSymlink(linkPath)) throw new Error(`${linkPath} already exists and is not a symlink`) await rm(linkPath, { force: true }) await symlink(targetPath, linkPath) } async function existingNonSymlink(path: string): Promise { try { const stat = await lstat(path) if (!stat.isSymbolicLink()) return true await readlink(path) return false } catch (error) { if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false throw error } } function shouldCopyPluginPath(path: string, root: string): boolean { const relative = path === root ? "" : path.slice(root.length + sep.length) if (relative === "") return true const parts = relative.split(sep) return !parts.some((part) => part === ".git" || part === "node_modules") } async function exists(path: string): Promise { try { await lstat(path) return true } catch { return false } } function isNodeErrorWithCode(error: unknown): error is NodeJS.ErrnoException { return typeof error === "object" && error !== null && "code" in error } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) }