feat(cli): add --codex installer + lazycodex auto-routing via OMO_INVOCATION_NAME
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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<InstalledPlugin> {
|
||||
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<void> {
|
||||
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<readonly { name: string; path: string; target: string }[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!(await exists(join(cwd, "package.json")))) return
|
||||
await runCommand("npm", args, { cwd })
|
||||
}
|
||||
|
||||
async function maybeRunNpmBuild(cwd: string, runCommand: RunCommand): Promise<void> {
|
||||
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<void> {
|
||||
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<readonly { name: string; target: string }[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
Reference in New Issue
Block a user