From 4279826b6c5b11dfab457319548fb9f880366c9c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:05 +0900 Subject: [PATCH] test(script): batch 28 (3 files) --- script/lazycodex-marketplace-validation.ts | 123 ++++++++++++++ script/sync-lazycodex-marketplace.test.ts | 147 ++++++++++++++++ script/sync-lazycodex-marketplace.ts | 185 +++++++++++++++++++++ 3 files changed, 455 insertions(+) create mode 100644 script/lazycodex-marketplace-validation.ts create mode 100644 script/sync-lazycodex-marketplace.test.ts create mode 100644 script/sync-lazycodex-marketplace.ts diff --git a/script/lazycodex-marketplace-validation.ts b/script/lazycodex-marketplace-validation.ts new file mode 100644 index 000000000..9e81ccb88 --- /dev/null +++ b/script/lazycodex-marketplace-validation.ts @@ -0,0 +1,123 @@ +import { readFile, readdir, stat } from "node:fs/promises" +import { dirname, join, resolve, sep } from "node:path" + +export async function validateLazycodexPluginBundle(pluginRoot: string): Promise { + await validatePluginMcpManifest(pluginRoot) + await validatePluginHookCommands(pluginRoot) +} + +async function validatePluginMcpManifest(pluginRoot: string): Promise { + const manifestPath = join(pluginRoot, ".mcp.json") + if (!(await isFile(manifestPath))) return + + const parsed: unknown = JSON.parse(await readFile(manifestPath, "utf8")) + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return + + for (const [serverName, server] of Object.entries(parsed.mcpServers)) { + if (!isRecord(server) || !Array.isArray(server.args)) continue + for (const arg of server.args) { + if (typeof arg !== "string" || !isPluginRuntimePathArg(arg)) continue + await validateRelativeBundleFile(pluginRoot, pluginRoot, arg, `missing MCP runtime path for ${serverName}`) + } + } +} + +async function validatePluginHookCommands(pluginRoot: string): Promise { + const hookManifestPaths = await findHookManifestPaths(pluginRoot) + for (const hookManifestPath of hookManifestPaths) { + const parsed: unknown = JSON.parse(await readFile(hookManifestPath, "utf8")) + const commands: string[] = [] + const hookPluginRoot = dirname(dirname(hookManifestPath)) + collectHookCommands(parsed, commands) + for (const command of commands) { + for (const relativePath of extractPluginRootPaths(command)) { + const hookCommandRoot = relativePath.startsWith("components/") ? pluginRoot : hookPluginRoot + await validateRelativeBundleFile(pluginRoot, hookCommandRoot, relativePath, "missing hook command target") + } + } + } +} + +async function findHookManifestPaths(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }) + const paths: string[] = [] + + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".git") continue + const entryPath = join(root, entry.name) + if (entry.isDirectory()) { + paths.push(...await findHookManifestPaths(entryPath)) + continue + } + if (entry.isFile() && entry.name === "hooks.json" && root.endsWith(`${sep}hooks`)) { + paths.push(entryPath) + } + } + + return paths +} + +function collectHookCommands(value: unknown, commands: string[]): void { + if (Array.isArray(value)) { + for (const item of value) { + collectHookCommands(item, commands) + } + return + } + + if (!isRecord(value)) return + if (value.type === "command" && typeof value.command === "string") { + commands.push(value.command) + } + for (const child of Object.values(value)) { + collectHookCommands(child, commands) + } +} + +function extractPluginRootPaths(command: string): string[] { + const paths: string[] = [] + const pluginRootPathPattern = /\$\{PLUGIN_ROOT\}\/([^"'\s]+)/g + let match = pluginRootPathPattern.exec(command) + while (match) { + const relativePath = match[1] + if (relativePath) { + paths.push(relativePath) + } + match = pluginRootPathPattern.exec(command) + } + return paths +} + +function isPluginRuntimePathArg(arg: string): boolean { + return (arg.startsWith("./") || arg.startsWith("../")) && arg.endsWith("/dist/cli.js") +} + +async function validateRelativeBundleFile( + bundleRoot: string, + baseRoot: string, + relativePath: string, + message: string, +): Promise { + const targetPath = resolve(baseRoot, relativePath) + const bundleRootPath = resolve(bundleRoot) + const bundleRootPrefix = bundleRootPath.endsWith(sep) ? bundleRootPath : `${bundleRootPath}${sep}` + if (targetPath !== bundleRootPath && !targetPath.startsWith(bundleRootPrefix)) { + throw new Error(`${message}: ${relativePath} escapes plugin root`) + } + if (!(await isFile(targetPath))) { + throw new Error(`${message}: ${relativePath}`) + } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch (error) { + if (error instanceof Error) return false + return false + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/script/sync-lazycodex-marketplace.test.ts b/script/sync-lazycodex-marketplace.test.ts new file mode 100644 index 000000000..b6b94e91a --- /dev/null +++ b/script/sync-lazycodex-marketplace.test.ts @@ -0,0 +1,147 @@ +/// + +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { syncLazycodexMarketplace } from "./sync-lazycodex-marketplace" + +async function writeJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`) +} + +async function writePluginFixture(sourceRoot: string): Promise { + await writeJson(join(sourceRoot, "packages", "omo-codex", "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }) + await writeJson(join(sourceRoot, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json"), { + name: "omo", + version: "1.2.3", + }) + await writeJson(join(sourceRoot, "packages", "omo-codex", "plugin", ".mcp.json"), { + mcpServers: { + ast_grep: { command: "node", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"], cwd: "." }, + lsp: { command: "node", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], cwd: "." }, + }, + }) + await writeFile(join(sourceRoot, "packages", "omo-codex", "plugin", "README.md"), "omo\n") + await mkdir(join(sourceRoot, "packages", "omo-codex", "plugin", "components", "lsp", "dist"), { recursive: true }) + await writeFile(join(sourceRoot, "packages", "omo-codex", "plugin", "components", "lsp", "dist", "cli.js"), "#!/usr/bin/env node\n") + await mkdir(join(sourceRoot, "packages", "ast-grep-mcp", "dist"), { recursive: true }) + await writeFile(join(sourceRoot, "packages", "ast-grep-mcp", "dist", "cli.js"), "#!/usr/bin/env node\n") + await mkdir(join(sourceRoot, "packages", "lsp-tools-mcp", "dist"), { recursive: true }) + await writeFile(join(sourceRoot, "packages", "lsp-tools-mcp", "dist", "cli.js"), "#!/usr/bin/env node\n") + await mkdir(join(sourceRoot, "packages", "omo-codex", "plugin", "node_modules", "ignored"), { recursive: true }) + await writeFile(join(sourceRoot, "packages", "omo-codex", "plugin", "node_modules", "ignored", "file.txt"), "ignored\n") +} + +describe("sync-lazycodex-marketplace", () => { + test("#given marketplace sync #when copying plugin bundle #then emits self-contained mcp paths", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-sync-source-")) + const lazycodexRoot = await mkdtemp(join(tmpdir(), "omo-sync-lazycodex-")) + await writePluginFixture(sourceRoot) + + // when + await syncLazycodexMarketplace({ sourceRoot, lazycodexRoot }) + + // then + const marketplace = JSON.parse(await readFile(join(lazycodexRoot, ".agents", "plugins", "marketplace.json"), "utf8")) + expect(marketplace.name).toBe("sisyphuslabs") + expect(marketplace.plugins[0].source).toBe("./plugins/omo") + const manifest = JSON.parse(await readFile(join(lazycodexRoot, "plugins", "omo", ".codex-plugin", "plugin.json"), "utf8")) + expect(manifest).toMatchObject({ name: "omo", version: "1.2.3" }) + const mcpManifest = JSON.parse(await readFile(join(lazycodexRoot, "plugins", "omo", ".mcp.json"), "utf8")) + expect(mcpManifest.mcpServers.ast_grep.args[0]).toBe("./components/ast-grep-mcp/dist/cli.js") + expect(mcpManifest.mcpServers.lsp.args[0]).toBe("./components/lsp-tools-mcp/dist/cli.js") + expect((await stat(join(lazycodexRoot, "plugins", "omo", "components", "ast-grep-mcp", "dist", "cli.js"))).isFile()).toBe(true) + expect((await stat(join(lazycodexRoot, "plugins", "omo", "components", "lsp-tools-mcp", "dist", "cli.js"))).isFile()).toBe(true) + let nodeModulesMissing = false + try { + await stat(join(lazycodexRoot, "plugins", "omo", "node_modules")) + } catch (error) { + nodeModulesMissing = error instanceof Error + } + expect(nodeModulesMissing).toBe(true) + }) + + test("rejects a source tree without a Codex plugin manifest", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-sync-bad-source-")) + const lazycodexRoot = await mkdtemp(join(tmpdir(), "omo-sync-bad-lazycodex-")) + await writeJson(join(sourceRoot, "packages", "omo-codex", "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }) + + // when + let message = "" + try { + await syncLazycodexMarketplace({ sourceRoot, lazycodexRoot }) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + // then + expect(message).toContain("missing Codex plugin manifest") + }) + + test("#given stale mcp runtime path #when syncing marketplace #then rejects the broken bundle", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-sync-stale-mcp-source-")) + const lazycodexRoot = await mkdtemp(join(tmpdir(), "omo-sync-stale-mcp-lazycodex-")) + await writePluginFixture(sourceRoot) + await writeJson(join(sourceRoot, "packages", "omo-codex", "plugin", ".mcp.json"), { + mcpServers: { + lsp: { command: "node", args: ["./components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"], cwd: "." }, + }, + }) + + // when + let message = "" + try { + await syncLazycodexMarketplace({ sourceRoot, lazycodexRoot }) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + // then + expect(message).toContain("missing MCP runtime path") + expect(message).toContain("components/lsp/packages/lsp-tools-mcp/dist/cli.js") + }) + + test("#given missing hook command target #when syncing marketplace #then rejects the broken bundle", async () => { + // given + const sourceRoot = await mkdtemp(join(tmpdir(), "omo-sync-missing-hook-source-")) + const lazycodexRoot = await mkdtemp(join(tmpdir(), "omo-sync-missing-hook-lazycodex-")) + await writePluginFixture(sourceRoot) + await writeJson(join(sourceRoot, "packages", "omo-codex", "plugin", "hooks", "hooks.json"), { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: "node \"${PLUGIN_ROOT}/components/rules/dist/cli.js\" hook session-start", + }, + ], + }, + ], + }, + }) + + // when + let message = "" + try { + await syncLazycodexMarketplace({ sourceRoot, lazycodexRoot }) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + + // then + expect(message).toContain("missing hook command target") + expect(message).toContain("components/rules/dist/cli.js") + }) +}) diff --git a/script/sync-lazycodex-marketplace.ts b/script/sync-lazycodex-marketplace.ts new file mode 100644 index 000000000..209742781 --- /dev/null +++ b/script/sync-lazycodex-marketplace.ts @@ -0,0 +1,185 @@ +import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises" +import { dirname, join, resolve, sep } from "node:path" +import { validateLazycodexPluginBundle } from "./lazycodex-marketplace-validation" + +const MARKETPLACE_SOURCE_PATH = join("packages", "omo-codex", "marketplace.json") +const PLUGIN_SOURCE_PATH = join("packages", "omo-codex", "plugin") +const AST_GREP_MCP_DIST_SOURCE_PATH = join("packages", "ast-grep-mcp", "dist") +const LSP_TOOLS_MCP_DIST_SOURCE_PATH = join("packages", "lsp-tools-mcp", "dist") +const MARKETPLACE_DESTINATION_PATH = join(".agents", "plugins", "marketplace.json") +const PLUGIN_DESTINATION_PATH = join("plugins", "omo") +const AST_GREP_MCP_DIST_DESTINATION_PATH = join(PLUGIN_DESTINATION_PATH, "components", "ast-grep-mcp", "dist") +const LSP_TOOLS_MCP_DIST_DESTINATION_PATH = join(PLUGIN_DESTINATION_PATH, "components", "lsp-tools-mcp", "dist") +const AST_GREP_MCP_SOURCE_ARG = "../../ast-grep-mcp/dist/cli.js" +const AST_GREP_MCP_PLUGIN_ARG = "./components/ast-grep-mcp/dist/cli.js" +const LSP_TOOLS_MCP_SOURCE_ARG = "../../lsp-tools-mcp/dist/cli.js" +const LSP_TOOLS_MCP_PLUGIN_ARG = "./components/lsp-tools-mcp/dist/cli.js" + +const BUNDLED_MCP_DISTS = [ + { + label: "ast-grep MCP", + sourcePath: AST_GREP_MCP_DIST_SOURCE_PATH, + destinationPath: AST_GREP_MCP_DIST_DESTINATION_PATH, + }, + { + label: "LSP MCP", + sourcePath: LSP_TOOLS_MCP_DIST_SOURCE_PATH, + destinationPath: LSP_TOOLS_MCP_DIST_DESTINATION_PATH, + }, +] as const + +const MCP_ARG_REWRITES = [ + [AST_GREP_MCP_SOURCE_ARG, AST_GREP_MCP_PLUGIN_ARG], + [LSP_TOOLS_MCP_SOURCE_ARG, LSP_TOOLS_MCP_PLUGIN_ARG], +] as const + +export interface SyncLazycodexMarketplaceInput { + readonly sourceRoot: string + readonly lazycodexRoot: string +} + +interface MarketplaceManifest { + readonly name: string +} + +interface PluginManifest { + readonly name: string + readonly version?: string +} + +export async function syncLazycodexMarketplace(input: SyncLazycodexMarketplaceInput): Promise { + const sourceRoot = resolve(input.sourceRoot) + const lazycodexRoot = resolve(input.lazycodexRoot) + const marketplacePath = join(sourceRoot, MARKETPLACE_SOURCE_PATH) + const pluginRoot = join(sourceRoot, PLUGIN_SOURCE_PATH) + const pluginManifestPath = join(pluginRoot, ".codex-plugin", "plugin.json") + + const marketplace = await readMarketplaceManifest(marketplacePath) + if (marketplace.name !== "sisyphuslabs") { + throw new Error(`Sisyphus Labs marketplace manifest must be named sisyphuslabs, got ${marketplace.name}`) + } + + const pluginManifest = await readPluginManifest(pluginManifestPath) + if (pluginManifest.name !== "omo") { + throw new Error(`Sisyphus Labs plugin manifest must be named omo, got ${pluginManifest.name}`) + } + + const destinationMarketplacePath = join(lazycodexRoot, MARKETPLACE_DESTINATION_PATH) + await mkdir(dirname(destinationMarketplacePath), { recursive: true }) + await writeFile(destinationMarketplacePath, await readFile(marketplacePath, "utf8")) + + const destinationPluginRoot = join(lazycodexRoot, PLUGIN_DESTINATION_PATH) + await rm(destinationPluginRoot, { recursive: true, force: true }) + await mkdir(dirname(destinationPluginRoot), { recursive: true }) + await cp(pluginRoot, destinationPluginRoot, { + recursive: true, + filter: (path) => shouldCopyPluginPath(path, pluginRoot), + }) + await copyBundledMcpDists(sourceRoot, lazycodexRoot) + await rewritePluginMcpManifest(destinationPluginRoot) + await validateLazycodexPluginBundle(destinationPluginRoot) +} + +async function readMarketplaceManifest(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) + if (isRecord(parsed) && typeof parsed.name === "string") { + return { name: parsed.name } + } + throw new Error("invalid Sisyphus Labs marketplace manifest") +} + +async function readPluginManifest(path: string): Promise { + if (!(await isFile(path))) { + throw new Error(`missing Codex plugin manifest at ${path}`) + } + const parsed = JSON.parse(await readFile(path, "utf8")) + if (isRecord(parsed) && typeof parsed.name === "string") { + return { + name: parsed.name, + version: typeof parsed.version === "string" ? parsed.version : undefined, + } + } + throw new Error("invalid Codex plugin manifest") +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile() + } catch (error) { + if (error instanceof Error) return false + return false + } +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch (error) { + if (error instanceof Error) return false + return false + } +} + +async function copyBundledMcpDists(sourceRoot: string, lazycodexRoot: string): Promise { + for (const mcpDist of BUNDLED_MCP_DISTS) { + await copyBundledMcpDist(sourceRoot, lazycodexRoot, mcpDist) + } +} + +async function copyBundledMcpDist( + sourceRoot: string, + lazycodexRoot: string, + mcpDist: (typeof BUNDLED_MCP_DISTS)[number], +): Promise { + const sourcePath = join(sourceRoot, mcpDist.sourcePath) + if (!(await isDirectory(sourcePath))) { + throw new Error(`missing built ${mcpDist.label} dist at ${sourcePath}`) + } + const destinationPath = join(lazycodexRoot, mcpDist.destinationPath) + await mkdir(dirname(destinationPath), { recursive: true }) + await cp(sourcePath, destinationPath, { recursive: true }) +} + +async function rewritePluginMcpManifest(pluginRoot: string): Promise { + const manifestPath = join(pluginRoot, ".mcp.json") + if (!(await isFile(manifestPath))) return + const parsed: unknown = JSON.parse(await readFile(manifestPath, "utf8")) + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return + + let changed = false + for (const server of Object.values(parsed.mcpServers)) { + if (!isRecord(server) || !Array.isArray(server.args)) continue + const currentArgs = server.args + const nextArgs = currentArgs.map(rewriteMcpArg) + if (nextArgs.some((arg, index) => arg !== currentArgs[index])) { + server.args = nextArgs + changed = true + } + } + if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`) +} + +function rewriteMcpArg(arg: unknown): unknown { + if (typeof arg !== "string") return arg + const rewrite = MCP_ARG_REWRITES.find(([sourceArg]) => sourceArg === arg) + return rewrite?.[1] ?? arg +} + +function shouldCopyPluginPath(path: string, root: string): boolean { + const relative = path === root ? "" : path.slice(root.length + sep.length) + if (relative.length === 0) return true + return !relative.split(sep).some((part) => part === ".git" || part === "node_modules") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +if (import.meta.main) { + const sourceRoot = process.argv[2] ?? process.cwd() + const lazycodexRoot = process.argv[3] + if (lazycodexRoot === undefined) { + throw new Error("Usage: bun run script/sync-lazycodex-marketplace.ts ") + } + await syncLazycodexMarketplace({ sourceRoot, lazycodexRoot }) +}