diff --git a/packages/omo-codex/scripts/install-agent-links.test.mjs b/packages/omo-codex/scripts/install-agent-links.test.mjs new file mode 100644 index 000000000..466557caa --- /dev/null +++ b/packages/omo-codex/scripts/install-agent-links.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { lstat, mkdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { installMarketplaceLocally } from "./install-local.mjs"; +import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs"; + +const legacyCodexPluginMarketplace = ["code", "yeongyu", "codex", "plugins"].join("-"); + +test( + "#given bundled agent roles and stale legacy links #when installing locally #then relinks Codex agents to marketplace snapshot", + { skip: process.platform === "win32" ? "Windows copies agent files instead of symlinking them" : false }, + async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + const agentsRoot = join(pluginRoot, "components", "ultrawork", "agents"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(pluginRoot, "omo", "0.1.0"); + await mkdir(agentsRoot, { recursive: true }); + for (const agentName of ["explorer", "librarian", "plan"]) { + await writeFile(join(agentsRoot, `${agentName}.toml`), `name = "${agentName}"\n`); + } + await mkdir(join(codexHome, "agents"), { recursive: true }); + await symlink( + join(codexHome, "plugins", "cache", legacyCodexPluginMarketplace, "omo", "0.1.0", "components", "ultrawork", "agents", "explorer.toml"), + join(codexHome, "agents", "explorer.toml"), + ); + + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + platform: "linux", + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.installed.length, 1); + const snapshotPluginPath = join(codexHome, ".tmp", "marketplaces", "sisyphuslabs", "plugins", "omo"); + for (const agentName of ["explorer", "librarian", "plan"]) { + const agentPath = join(codexHome, "agents", `${agentName}.toml`); + assert.equal((await lstat(agentPath)).isSymbolicLink(), true); + assert.equal(await readlink(agentPath), join(snapshotPluginPath, "components", "ultrawork", "agents", `${agentName}.toml`)); + assert.equal(await readFile(agentPath, "utf8"), `name = "${agentName}"\n`); + } + + const installedAgents = JSON.parse(await readFile(join(snapshotPluginPath, ".installed-agents.json"), "utf8")); + assert.deepEqual(installedAgents.agents.sort(), [ + join(codexHome, "agents", "explorer.toml"), + join(codexHome, "agents", "librarian.toml"), + join(codexHome, "agents", "plan.toml"), + ]); + }, +); + +test( + "#given local sisyphuslabs install #when plugin cache is pruned #then agent links still resolve through marketplace snapshot", + { skip: process.platform === "win32" ? "Windows copies agent files instead of symlinking them" : false }, + async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + const agentsRoot = join(pluginRoot, "components", "ultrawork", "agents"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(pluginRoot, "omo", "0.1.0"); + await mkdir(agentsRoot, { recursive: true }); + await writeFile(join(agentsRoot, "explorer.toml"), 'name = "explorer"\n'); + const snapshotRoot = join(codexHome, ".tmp", "marketplaces", "sisyphuslabs"); + await mkdir(join(snapshotRoot, ".git"), { recursive: true }); + await writeFile(join(snapshotRoot, ".git", "config"), "[remote \"origin\"]\n"); + await writeFile(join(snapshotRoot, ".codex-marketplace-install.json"), '{"source_type":"git"}\n'); + + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + platform: "linux", + runCommand: async () => {}, + log: () => {}, + }); + + const pluginPath = result.installed[0].path; + await rm(pluginPath, { recursive: true, force: true }); + + const agentPath = join(codexHome, "agents", "explorer.toml"); + assert.equal( + await readlink(agentPath), + join(codexHome, ".tmp", "marketplaces", "sisyphuslabs", "plugins", "omo", "components", "ultrawork", "agents", "explorer.toml"), + ); + assert.equal(await readFile(agentPath, "utf8"), 'name = "explorer"\n'); + assert.equal(await readFile(join(snapshotRoot, ".git", "config"), "utf8"), "[remote \"origin\"]\n"); + assert.equal(await readFile(join(snapshotRoot, ".codex-marketplace-install.json"), "utf8"), '{"source_type":"git"}\n'); + }, +); diff --git a/packages/omo-codex/scripts/install-bin-links.test.mjs b/packages/omo-codex/scripts/install-bin-links.test.mjs new file mode 100644 index 000000000..4b9320907 --- /dev/null +++ b/packages/omo-codex/scripts/install-bin-links.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, readlink, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { linkCachedPluginBins } from "./install/cache.mjs"; +import { makeTempDir, writeJson } from "./install-test-fixtures.mjs"; + +test("#given Windows platform #when linking cached plugin bins #then writes command shims", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + + await mkdir(pluginRoot, { recursive: true }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/alpha", + bin: { + alpha: "./dist/cli.js", + }, + }); + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n"); + + const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }); + + assert.deepEqual(linked, [{ name: "alpha", path: join(binDir, "alpha.cmd"), target: join(pluginRoot, "dist", "cli.js") }]); + const shim = await readFile(join(binDir, "alpha.cmd"), "utf8"); + assert.match(shim, /@echo off/); + assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`)); +}); + +test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + + await mkdir(pluginRoot, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/alpha", + bin: { + alpha: "./dist/cli.js", + }, + }); + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n"); + + await assert.rejects( + linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }), + /already exists and is not a generated command shim/, + ); + assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/); +}); + +test("#given managed legacy Codex component symlink #when linking bins #then removes stale symlink and writes OMO bin", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + const oldTarget = join(root, "codex-home", "plugins", "cache", "legacy-market", "omo", "0.0.1", "components", "rules", "dist", "cli.js"); + + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await mkdir(join(root, "codex-home", "plugins", "cache", "legacy-market", "omo", "0.0.1", "components", "rules", "dist"), { recursive: true }); + await mkdir(binDir, { recursive: true }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/omo", + bin: { "omo-rules": "./dist/cli.js" }, + }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeFile(oldTarget, "#!/usr/bin/env node\n"); + await symlink(oldTarget, join(binDir, "codex-rules")); + + await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }); + + await assert.rejects(readlink(join(binDir, "codex-rules"))); + assert.equal(await readlink(join(binDir, "omo-rules")), join(pluginRoot, "dist", "cli.js")); +}); + +test("#given user-owned legacy Codex symlink #when linking bins #then preserves the user symlink", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + const userTarget = join(root, "user-tools", "codex-rules"); + + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await mkdir(join(root, "user-tools"), { recursive: true }); + await mkdir(binDir, { recursive: true }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/omo", + bin: { "omo-rules": "./dist/cli.js" }, + }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeFile(userTarget, "#!/usr/bin/env node\n"); + await symlink(userTarget, join(binDir, "codex-rules")); + + await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }); + + assert.equal(await readlink(join(binDir, "codex-rules")), userTarget); + assert.equal(await readlink(join(binDir, "omo-rules")), join(pluginRoot, "dist", "cli.js")); +}); + +test("#given user-owned legacy Codex symlink with component-like target #when linking bins #then preserves it", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + const userTarget = join(root, "workspace", "components", "rules", "dist", "cli.js"); + + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await mkdir(join(root, "workspace", "components", "rules", "dist"), { recursive: true }); + await mkdir(binDir, { recursive: true }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/omo", + bin: { "omo-rules": "./dist/cli.js" }, + }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeFile(userTarget, "#!/usr/bin/env node\n"); + await symlink(userTarget, join(binDir, "codex-rules")); + + await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }); + + assert.equal(await readlink(join(binDir, "codex-rules")), userTarget); + assert.equal(await readlink(join(binDir, "omo-rules")), join(pluginRoot, "dist", "cli.js")); +}); diff --git a/packages/omo-codex/scripts/install-cache-copy.test.mjs b/packages/omo-codex/scripts/install-cache-copy.test.mjs new file mode 100644 index 000000000..daddde5ae --- /dev/null +++ b/packages/omo-codex/scripts/install-cache-copy.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { installCachedPlugin } from "./install/cache.mjs"; +import { makeTempDir } from "./install-test-fixtures.mjs"; + +test("#given source plugin has a stale npm lockfile #when caching plugin #then lockfile is regenerated rather than copied", async () => { + // given + const root = await makeTempDir(); + const codexHome = join(root, "codex-home"); + const sourceRoot = join(root, "plugin"); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(join(sourceRoot, "package.json"), JSON.stringify({ name: "@scope/omo", version: "0.1.0" })); + await writeFile(join(sourceRoot, "package-lock.json"), '{"packages":{"components/ulw-loop":{}}}\n'); + + // when + const installed = await installCachedPlugin({ + codexHome, + marketplaceName: "debug", + name: "omo", + sourcePath: sourceRoot, + version: "0.1.0", + runCommand: async () => {}, + }); + + // then + await assert.rejects(stat(join(installed.path, "package-lock.json"))); +}); diff --git a/packages/omo-codex/scripts/install-config.test.mjs b/packages/omo-codex/scripts/install-config.test.mjs new file mode 100644 index 000000000..e1fb42e80 --- /dev/null +++ b/packages/omo-codex/scripts/install-config.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { updateCodexConfig } from "./install/config.mjs"; + +test("#given empty Codex config #when script installer updates config #then enables MultiAgentV2 with ten thousand session threads", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-script-config-multi-agent-")); + const configPath = join(root, "config.toml"); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }); + + // then + const config = await readFile(configPath, "utf8"); + assert.match(config, /\[features\.multi_agent_v2\]/); + assert.match(config, /enabled = true/); + assert.match(config, /max_concurrent_threads_per_session = 10000/); +}); + +test("#given sisyphuslabs config without explicit source #when script installer updates config #then uses local marketplace", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-script-config-sisyphuslabs-")); + const configPath = join(root, "config.toml"); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + pluginNames: ["omo"], + }); + + // then + const config = await readFile(configPath, "utf8"); + assert.match(config, /\[marketplaces\.sisyphuslabs\]/); + assert.match(config, /source_type = "local"/); + assert.match(config, /source = "\/repo\/packages\/omo-codex"/); + assert.doesNotMatch(config, /lazycodex\.git/); + assert.doesNotMatch(config, /ref = "main"/); +}); + +test("#given existing MultiAgentV2 table #when script installer updates config #then preserves unrelated tuning while setting ten thousand session threads", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-script-config-multi-agent-existing-")); + const configPath = join(root, "config.toml"); + await writeFile( + configPath, + [ + "[features.multi_agent_v2]", + "enabled = false", + "usage_hint_enabled = false", + "max_concurrent_threads_per_session = 4", + "", + ].join("\n"), + ); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }); + + // then + const config = await readFile(configPath, "utf8"); + assert.match(config, /\[features\.multi_agent_v2\]/); + assert.match(config, /enabled = true/); + assert.match(config, /usage_hint_enabled = false/); + assert.match(config, /max_concurrent_threads_per_session = 10000/); + assert.doesNotMatch(config, /max_concurrent_threads_per_session = 4/); +}); + +test("#given legacy boolean MultiAgentV2 flag and table #when script installer updates config #then normalizes to table config", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-script-config-multi-agent-legacy-")); + const configPath = join(root, "config.toml"); + await writeFile( + configPath, + [ + "[features]", + "multi_agent_v2 = true", + "plugins = false", + "", + "[features.multi_agent_v2]", + "usage_hint_enabled = false", + "", + ].join("\n"), + ); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "debug", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" }, + pluginNames: ["omo"], + }); + + // then + const config = await readFile(configPath, "utf8"); + assert.doesNotMatch(config, /^multi_agent_v2\s*=/m); + assert.match(config, /\[features\.multi_agent_v2\]/); + assert.match(config, /enabled = true/); + assert.match(config, /usage_hint_enabled = false/); + assert.match(config, /max_concurrent_threads_per_session = 10000/); +}); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs new file mode 100644 index 000000000..470ffecbc --- /dev/null +++ b/packages/omo-codex/scripts/install-local.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import { mkdir, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + installCachedPlugin, + linkCachedPluginBins, + pruneMarketplaceCache, + pruneMarketplacePluginCaches, +} from "./install/cache.mjs"; +import { linkCachedPluginAgents } from "./install/agents.mjs"; +import { updateCodexConfig } from "./install/config.mjs"; +import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs"; +import { defaultRunCommand } from "./install/process.mjs"; +import { writeInstalledMarketplaceSnapshot } from "./install/snapshot.mjs"; +import { + readMarketplace, + readPluginManifest, + resolvePluginSource, + validatePathSegment, +} from "./install/marketplace.mjs"; + +const LEGACY_CODEX_PLUGIN_MARKETPLACE = ["code", "yeongyu", "codex", "plugins"].join("-"); +const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", LEGACY_CODEX_PLUGIN_MARKETPLACE]; + +export function resolveCodexInstallerBinDir(options = {}) { + const homeDir = resolve(options.homeDir ?? homedir()); + const env = options.env ?? process.env; + const explicitBinDir = nonEmptyEnvValue(env, "CODEX_LOCAL_BIN_DIR"); + if (explicitBinDir !== undefined) return explicitBinDir; + + const codexHome = resolve(options.codexHome ?? nonEmptyEnvValue(env, "CODEX_HOME") ?? join(homeDir, ".codex")); + const defaultCodexHome = resolve(join(homeDir, ".codex")); + return codexHome === defaultCodexHome ? join(homeDir, ".local", "bin") : join(codexHome, "bin"); +} + +export async function installMarketplaceLocally(options = {}) { + const repoRoot = resolve(options.repoRoot ?? process.cwd()); + const env = options.env ?? process.env; + const homeDir = resolve(options.homeDir ?? homedir()); + const codexHome = resolve(options.codexHome ?? nonEmptyEnvValue(env, "CODEX_HOME") ?? join(homeDir, ".codex")); + const binDir = resolve(options.binDir ?? resolveCodexInstallerBinDir({ codexHome, env, homeDir })); + const platform = options.platform ?? process.platform; + const runCommand = options.runCommand ?? defaultRunCommand; + const log = options.log ?? console.log; + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const marketplace = await readMarketplace(repoRoot, { + marketplacePath: join(codexPackageRoot, "marketplace.json"), + }); + const installed = []; + const pluginSources = []; + const agentConfigs = new Map(); + + for (const entry of marketplace.plugins) { + const sourcePath = resolvePluginSource(codexPackageRoot, entry, { pathOverride: "./plugin" }); + const manifest = await readPluginManifest(sourcePath); + if (manifest.name !== entry.name) { + throw new Error( + `plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`, + ); + } + const version = manifest.version ?? "local"; + validatePathSegment(version, "plugin version"); + + log(`Building ${entry.name}@${version}`); + const plugin = await installCachedPlugin({ + codexHome, + marketplaceName: marketplace.name, + name: entry.name, + runCommand, + sourcePath, + version, + }); + const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path, platform }); + for (const link of binLinks) { + log(`Linked ${link.name} -> ${link.target}`); + } + pluginSources.push({ name: entry.name, sourcePath }); + installed.push(plugin); + } + + const agentSourceRoots = await agentSourceRootsForInstall({ codexHome, marketplace, installed, pluginSources }); + for (const plugin of installed) { + const pluginRoot = agentSourceRoots.get(plugin.name) ?? plugin.path; + const agentLinks = await linkCachedPluginAgents({ codexHome, pluginRoot, platform }); + for (const link of agentLinks) { + log(`Linked agent ${link.name} -> ${link.target}`); + const agentName = agentNameFromToml(link.name); + agentConfigs.set(agentName, { name: agentName, configFile: `./agents/${link.name}` }); + } + } + + const pluginNames = marketplace.plugins.map((plugin) => plugin.name); + const trustedHookStates = ( + await Promise.all( + installed.map((plugin) => + trustedHookStatesForPlugin({ + marketplaceName: marketplace.name, + pluginName: plugin.name, + pluginRoot: plugin.path, + }), + ), + ) + ).flat(); + await pruneMarketplaceCache({ codexHome, marketplaceName: marketplace.name, keepPluginNames: pluginNames }); + for (const legacyMarketplaceName of legacyCacheMarketplaces(marketplace.name)) { + await pruneMarketplacePluginCaches({ codexHome, marketplaceName: legacyMarketplaceName, pluginNames }); + } + const marketplaceRoot = join(codexHome, "plugins", "cache", marketplace.name); + await writeCachedMarketplaceManifest({ + marketplaceName: marketplace.name, + marketplaceRoot, + plugins: installed, + }); + await updateCodexConfig({ + configPath: join(codexHome, "config.toml"), + repoRoot: codexPackageRoot, + marketplaceName: marketplace.name, + marketplaceSource: { sourceType: "local", source: marketplaceRoot }, + pluginNames, + trustedHookStates, + agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), + }); + + for (const plugin of installed) { + log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`); + } + + return { marketplaceName: marketplace.name, installed }; +} + +function agentNameFromToml(fileName) { + return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName; +} + +async function agentSourceRootsForInstall({ codexHome, marketplace, installed, pluginSources }) { + if (marketplace.name !== "sisyphuslabs") { + return new Map(installed.map((plugin) => [plugin.name, plugin.path])); + } + const snapshotPlugins = await writeInstalledMarketplaceSnapshot({ + codexHome, + marketplace, + plugins: pluginSources, + }); + return new Map(snapshotPlugins.map((plugin) => [plugin.name, plugin.path])); +} + +async function writeCachedMarketplaceManifest({ marketplaceName, marketplaceRoot, plugins }) { + const marketplaceDir = join(marketplaceRoot, ".agents", "plugins"); + await mkdir(marketplaceDir, { recursive: true }); + await writeFile( + join(marketplaceDir, "marketplace.json"), + `${JSON.stringify( + { + name: marketplaceName, + plugins: plugins.map((plugin) => ({ + name: plugin.name, + source: { source: "local", path: `./${plugin.name}/${plugin.version}` }, + })), + }, + null, + "\t", + )}\n`, + ); +} + +function nonEmptyEnvValue(env, key) { + const value = env[key]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : value; +} + +function legacyCacheMarketplaces(marketplaceName) { + return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_CACHE_MARKETPLACES : []; +} + +async function main() { + const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd(); + const result = await installMarketplaceLocally({ repoRoot }); + console.log(`Installed ${result.installed.length} plugin(s) from ${result.marketplaceName}.`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs new file mode 100644 index 000000000..767138e93 --- /dev/null +++ b/packages/omo-codex/scripts/install-local.test.mjs @@ -0,0 +1,378 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, readlink, stat, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { installMarketplaceLocally, resolveCodexInstallerBinDir } from "./install-local.mjs"; +import { makeTempDir, writeJson, writePluginAt } from "./install-test-fixtures.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const legacyCodexPluginMarketplace = ["code", "yeongyu", "codex", "plugins"].join("-"); + +test("#given default CODEX_HOME #when resolving local installer bin dir without override #then preserves user local bin precedence", () => { + const homeDir = join("/tmp", "omo-codex-home-default"); + const codexHome = join(homeDir, ".codex"); + + assert.equal(resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir }), join(homeDir, ".local", "bin")); +}); + +test("#given custom CODEX_HOME #when resolving local installer bin dir without override #then keeps generated omo inside that Codex home", () => { + const homeDir = join("/tmp", "omo-codex-home-custom"); + const codexHome = join("/tmp", "omo-codex-install-custom"); + + assert.equal(resolveCodexInstallerBinDir({ codexHome, env: {}, homeDir }), join(codexHome, "bin")); +}); + +test("#given custom CODEX_HOME and PATH without omo #when installing locally without bin override #then bootstraps via local CLI when omo is absent", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const homeDir = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(pluginRoot, "omo", "0.1.0"); + + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + env: { PATH: "/usr/bin:/bin" }, + homeDir, + platform: "linux", + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.installed.length, 1); + assert.equal(await readlink(join(codexHome, "bin", "omo")), join(result.installed[0].path, "dist", "cli.js")); +}); + +test("#given explicit CODEX_LOCAL_BIN_DIR #when resolving local installer bin dir #then preserves installed omo precedence", () => { + const homeDir = join("/tmp", "omo-codex-home-explicit"); + const codexHome = join("/tmp", "omo-codex-install-explicit"); + const explicitBinDir = join("/tmp", "omo-codex-explicit-bin"); + + assert.equal( + resolveCodexInstallerBinDir({ + codexHome, + env: { CODEX_LOCAL_BIN_DIR: explicitBinDir }, + homeDir, + }), + explicitBinDir, + ); +}); + +test("#given omo plugin source #when inspecting identity #then uses sisyphuslabs omo metadata", async () => { + const pluginRoot = join(scriptDir, "..", "plugin"); + + const manifest = JSON.parse(await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8")); + const packageJson = JSON.parse(await readFile(join(pluginRoot, "package.json"), "utf8")); + + assert.equal(packageJson.name, "@sisyphuslabs/omo-codex-plugin"); + assert.equal(manifest.homepage, "https://github.com/sisyphuslabs/omo"); + assert.equal(manifest.repository, "https://github.com/sisyphuslabs/omo"); + assert.equal(manifest.interface.websiteURL, "https://github.com/sisyphuslabs/omo"); + assert.equal(manifest.interface.privacyPolicyURL, "https://github.com/sisyphuslabs/omo#privacy"); + assert.equal(manifest.interface.termsOfServiceURL, "https://github.com/sisyphuslabs/omo#license"); +}); + +test("#given local marketplace #when installing #then copies versioned plugins and enables config", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const binDir = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + const pluginRoot = join(codexPackageRoot, "plugin"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "debug-marketplace", + plugins: [ + { + name: "alpha", + source: "./plugins/alpha", + }, + ], + }); + await writePluginAt(pluginRoot, "alpha", "1.2.3"); + await mkdir(join(codexPackageRoot, "shared-lsp", "dist"), { recursive: true }); + await writeJson(join(codexPackageRoot, "shared-lsp", "package.json"), { + name: "@example/shared-lsp", + version: "0.0.0", + type: "module", + bin: { "shared-lsp": "./dist/cli.js" }, + }); + await writeFile(join(codexPackageRoot, "shared-lsp", "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/alpha", + version: "1.2.3", + bin: { + alpha: "./dist/cli.js", + }, + scripts: { + build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"", + }, + dependencies: { + "@example/shared-lsp": "file:../shared-lsp", + }, + }); + await writeJson(join(pluginRoot, ".mcp.json"), { + mcpServers: { + alpha: { + command: "node", + args: ["./dist/cli.js", "mcp"], + cwd: ".", + }, + shared: { + command: "node", + args: ["../shared-lsp/dist/cli.js", "mcp"], + cwd: ".", + }, + }, + }); + await mkdir(join(pluginRoot, "node_modules"), { recursive: true }); + await writeFile(join(pluginRoot, "node_modules", "skip.txt"), "skip"); + await mkdir(join(codexHome, "plugins", "cache", "debug-marketplace", "stale", "0.1.0"), { recursive: true }); + await writeFile( + join(codexHome, "config.toml"), + [ + '[plugins."stale@debug-marketplace"]', + "enabled = true", + "", + '[hooks.state."stale@debug-marketplace:hooks/hooks.json:user_prompt_submit:0:0"]', + 'trusted_hash = "sha256:old"', + "", + ].join("\n"), + ); + + const commands = []; + const result = await installMarketplaceLocally({ + repoRoot, + codexHome, + binDir, + platform: "linux", + runCommand: async (command, args, options) => { + commands.push([command, args, options.cwd]); + if (command === "npm" && args.join(" ") === "run build") { + await mkdir(join(options.cwd, "dist"), { recursive: true }); + await writeFile(join(options.cwd, "dist", "cli.js"), "#!/usr/bin/env node\nconsole.log(1)\n"); + } + }, + log: () => {}, + }); + + assert.deepEqual( + result.installed.map((plugin) => `${plugin.name}@${plugin.version}`), + ["alpha@1.2.3"], + ); + const alphaCacheRoot = join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3"); + assert.equal((await stat(join(alphaCacheRoot, "dist", "cli.js"))).isFile(), true); + assert.equal((await stat(join(alphaCacheRoot, ".mcp.json"))).isFile(), true); + assert.equal(await readlink(join(binDir, "alpha")), join(alphaCacheRoot, "dist", "cli.js")); + const alphaMcp = JSON.parse(await readFile(join(alphaCacheRoot, ".mcp.json"), "utf8")); + const sharedMcpCli = join(alphaCacheRoot, "mcp", "shared", "dist", "cli.js"); + assert.deepEqual(alphaMcp.mcpServers.alpha.args, [join(alphaCacheRoot, "dist", "cli.js"), "mcp"]); + assert.deepEqual(alphaMcp.mcpServers.shared.args, [sharedMcpCli, "mcp"]); + assert.equal((await stat(sharedMcpCli)).isFile(), true); + assert.equal( + Object.hasOwn(alphaMcp.mcpServers.alpha, "cwd"), + false, + "`cwd: \".\"` must be stripped so the spawned MCP server inherits the caller's workspace cwd", + ); + assert.equal(Object.hasOwn(alphaMcp.mcpServers.shared, "cwd"), false); + assert.equal(alphaMcp.mcpServers.alpha.command, "node"); + const alphaPackageJson = JSON.parse(await readFile(join(alphaCacheRoot, "package.json"), "utf8")); + assert.equal(alphaPackageJson.dependencies["@example/shared-lsp"], `file:${join(codexPackageRoot, "shared-lsp")}`); + await assert.rejects( + stat(join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3", "node_modules")), + /code: 'ENOENT'|ENOENT/, + ); + await assert.rejects( + stat(join(codexHome, "plugins", "cache", "debug-marketplace", "stale")), + /code: 'ENOENT'|ENOENT/, + ); + assert.deepEqual( + commands.map(([command, args, cwd]) => [command, args.join(" "), cwd]), + [ + ["npm", "install", pluginRoot], + ["npm", "run build", pluginRoot], + ["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3")], + ], + ); + + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + assert.match(config, /\[features\]\n(?:plugin_hooks = true\n)?plugins = true/); + assert.match(config, /\[marketplaces\.debug-marketplace\]/); + assert.match(config, /source_type = "local"/); + assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/); + assert.match(config, /\[agents\.explorer\]\nconfig_file = "\.\/agents\/explorer\.toml"/); + assert.match(config, /\[agents\.librarian\]\nconfig_file = "\.\/agents\/librarian\.toml"/); + assert.match(config, /\[agents\.plan\]\nconfig_file = "\.\/agents\/plan\.toml"/); + assert.doesNotMatch(config, /stale@debug-marketplace/); +}); + +test("#given sisyphuslabs marketplace #when installing #then registers the local built marketplace cache", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "sisyphuslabs", + plugins: [{ name: "omo", source: "./plugins/omo" }], + }); + await writePluginAt(join(codexPackageRoot, "plugin"), "omo", "0.1.0"); + await mkdir(join(repoRoot, "packages", "lsp-tools-mcp", "dist"), { recursive: true }); + await writeJson(join(repoRoot, "packages", "lsp-tools-mcp", "package.json"), { + name: "@example/lsp-tools-mcp", + version: "0.1.0", + type: "module", + bin: { "omo-lsp": "./dist/cli.js" }, + }); + await writeFile(join(repoRoot, "packages", "lsp-tools-mcp", "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeJson(join(codexPackageRoot, "plugin", ".mcp.json"), { + mcpServers: { + lsp: { + command: "node", + args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], + cwd: ".", + }, + }, + }); + await mkdir(join(codexHome, "plugins", "cache", legacyCodexPluginMarketplace, "omo", "0.1.0"), { + recursive: true, + }); + await writeJson(join(codexHome, "plugins", "cache", legacyCodexPluginMarketplace, "omo", "0.1.0", ".mcp.json"), { + mcpServers: { + lsp: { + command: "node", + args: ["old/components/lsp/packages/lsp-tools-mcp/dist/cli.js", "mcp"], + }, + }, + }); + const legacyPluginKey = `omo@${legacyCodexPluginMarketplace}`; + await writeFile( + join(codexHome, "config.toml"), + [ + `[marketplaces.${legacyCodexPluginMarketplace}]`, + 'last_updated = "2026-05-01T00:00:00Z"', + 'source_type = "git"', + 'source = "https://github.com/code-yeongyu/codex-plugins.git"', + "", + `[plugins.${JSON.stringify(legacyPluginKey)}]`, + "enabled = true", + "", + `[plugins.${JSON.stringify(legacyPluginKey)}.mcp_servers.lsp]`, + 'enabled = true', + "", + `[hooks.state.${JSON.stringify(`${legacyPluginKey}:hooks/hooks.json:post_tool_use:0:0`)}]`, + 'trusted_hash = "sha256:old"', + "", + ].join("\n"), + ); + + await installMarketplaceLocally({ + repoRoot, + codexHome, + runCommand: async () => {}, + log: () => {}, + }); + + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + assert.match(config, /\[marketplaces\.sisyphuslabs\]/); + assert.match(config, /source_type = "local"/); + assert.match(config, new RegExp(`source = ${JSON.stringify(join(codexHome, "plugins", "cache", "sisyphuslabs")).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + assert.doesNotMatch(config, /ref = "main"/); + assert.match(config, /\[plugins\."omo@sisyphuslabs"\]\nenabled = true/); + assert.doesNotMatch(config, /\[marketplaces\.lazycodex\]/); + assert.doesNotMatch(config, new RegExp(legacyCodexPluginMarketplace)); + assert.doesNotMatch(config, /lazycodex\.git/); + const marketplace = JSON.parse( + await readFile(join(codexHome, "plugins", "cache", "sisyphuslabs", ".agents", "plugins", "marketplace.json"), "utf8"), + ); + assert.deepEqual(marketplace.plugins, [{ name: "omo", source: { source: "local", path: "./omo/0.1.0" } }]); + const cachedMcp = JSON.parse( + await readFile(join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "0.1.0", ".mcp.json"), "utf8"), + ); + assert.equal( + cachedMcp.mcpServers.lsp.args[0], + join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "0.1.0", "mcp", "lsp", "dist", "cli.js"), + ); + assert.doesNotMatch(cachedMcp.mcpServers.lsp.args[0], /components\/lsp\/packages/); + assert.equal((await stat(cachedMcp.mcpServers.lsp.args[0])).isFile(), true); + const snapshotPluginRoot = join(codexHome, ".tmp", "marketplaces", "sisyphuslabs", "plugins", "omo"); + const snapshotMcp = JSON.parse(await readFile(join(snapshotPluginRoot, ".mcp.json"), "utf8")); + assert.equal( + snapshotMcp.mcpServers.lsp.args[0], + join(snapshotPluginRoot, "mcp", "lsp", "dist", "cli.js"), + ); + assert.doesNotMatch(snapshotMcp.mcpServers.lsp.args[0], /\.\.\/\.\.\/lsp-tools-mcp/); + assert.doesNotMatch(snapshotMcp.mcpServers.lsp.args[0], /components\/lsp\/packages/); + assert.equal((await stat(snapshotMcp.mcpServers.lsp.args[0])).isFile(), true); + await assert.rejects( + stat(join(codexHome, "plugins", "cache", legacyCodexPluginMarketplace, "omo")), + /code: 'ENOENT'|ENOENT/, + ); +}); + +test("#given plugin hooks #when installing #then records trusted hook hashes", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "debug-marketplace", + plugins: [{ name: "alpha", source: "./plugins/alpha" }], + }); + const pluginRoot = join(codexPackageRoot, "plugin"); + await writePluginAt(pluginRoot, "alpha", "1.2.3"); + await writeJson(join(pluginRoot, "hooks", "hooks.json"), { + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit", + timeout: 10, + statusMessage: "checking alpha", + }, + ], + }, + ], + }, + }); + + await installMarketplaceLocally({ + repoRoot, + codexHome, + runCommand: async () => {}, + log: () => {}, + }); + + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + assert.match(config, /\[hooks\.state\."alpha@debug-marketplace:hooks\/hooks\.json:user_prompt_submit:0:0"\]/); + assert.match(config, /trusted_hash = "sha256:[a-f0-9]{64}"/); +}); + +test("#given bad plugin source path #when installing #then rejects traversal", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); + + await writeJson(join(codexPackageRoot, "marketplace.json"), { + name: "debug-marketplace", + plugins: [ + { + name: "escape", + source: "../escape", + }, + ], + }); + + await assert.rejects( + installMarketplaceLocally({ repoRoot, codexHome, log: () => {} }), + /local plugin source path must start with \.\//, + ); +}); diff --git a/packages/omo-codex/scripts/install-mcp-runtime.test.mjs b/packages/omo-codex/scripts/install-mcp-runtime.test.mjs new file mode 100644 index 000000000..83d3507f2 --- /dev/null +++ b/packages/omo-codex/scripts/install-mcp-runtime.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +import { installCachedPlugin } from "./install/cache.mjs"; +import { createCachedMcpRuntimeArgRewriter } from "./install/mcp-runtime-cache.mjs"; +import { makeTempDir, writeJson } from "./install-test-fixtures.mjs"; + +test("#given external MCP package runtime #when installing cached plugin #then runtime is copied into the plugin cache", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin"); + const astGrepPackageRoot = join(repoRoot, "packages", "ast-grep-mcp"); + const lspPackageRoot = join(repoRoot, "packages", "lsp-tools-mcp"); + + await writeJson(join(astGrepPackageRoot, "package.json"), { + name: "@example/does-not-matter-either", + version: "0.1.0", + type: "module", + bin: { "omo-ast-grep": "./dist/cli.js" }, + }); + await writeJson(join(lspPackageRoot, "package.json"), { + name: "@example/does-not-matter", + version: "0.1.0", + type: "module", + bin: { "omo-lsp": "./dist/cli.js" }, + }); + await writeJson(join(sourceRoot, "package.json"), { + name: "@example/omo", + version: "0.1.0", + }); + await writeJson(join(sourceRoot, ".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 writeJson(join(astGrepPackageRoot, "dist", "cli.js"), { executable: true }); + await writeJson(join(lspPackageRoot, "dist", "cli.js"), { executable: true }); + await writeJson(join(lspPackageRoot, "dist", "lsp", "manager.js"), { copied: true }); + + const result = await installCachedPlugin({ + codexHome, + marketplaceName: "sisyphuslabs", + name: "omo", + runCommand: async () => {}, + sourcePath: sourceRoot, + version: "0.1.0", + }); + + const cachedMcp = JSON.parse(await readFile(join(result.path, ".mcp.json"), "utf8")); + const copiedAstGrepCli = join(result.path, "mcp", "ast_grep", "dist", "cli.js"); + const copiedCli = join(result.path, "mcp", "lsp", "dist", "cli.js"); + + assert.deepEqual(cachedMcp.mcpServers.ast_grep.args, [copiedAstGrepCli, "mcp"]); + assert.deepEqual(cachedMcp.mcpServers.lsp.args, [copiedCli, "mcp"]); + assert.equal(Object.hasOwn(cachedMcp.mcpServers.ast_grep, "cwd"), false); + assert.equal(Object.hasOwn(cachedMcp.mcpServers.lsp, "cwd"), false); + assert.equal((await stat(copiedAstGrepCli)).isFile(), true); + assert.equal((await stat(copiedCli)).isFile(), true); + assert.equal((await stat(join(result.path, "mcp", "lsp", "dist", "lsp", "manager.js"))).isFile(), true); +}); + +test("#given multiple args from one external MCP package #when rewriting #then copies the dist tree once and rewrites each runtime arg", async () => { + const repoRoot = await makeTempDir(); + const pluginRoot = join(repoRoot, "packages", "omo-codex", "plugin"); + const sourceRoot = pluginRoot; + const packageRoot = join(repoRoot, "packages", "multi-tool-mcp"); + const copiedRoots = []; + + await writeJson(join(packageRoot, "package.json"), { + name: "@example/multi-tool-mcp", + version: "0.1.0", + bin: { "multi-tool": "./dist/cli.js" }, + }); + await writeJson(join(packageRoot, "dist", "cli.js"), { executable: true }); + await writeJson(join(packageRoot, "dist", "worker.js"), { worker: true }); + + const rewrite = createCachedMcpRuntimeArgRewriter({ + copyDist: async (_source, target) => { + copiedRoots.push(target); + }, + }); + + const first = await rewrite({ arg: "../../multi-tool-mcp/dist/cli.js", pluginRoot, serverName: "multi", sourceRoot }); + const second = await rewrite({ arg: "../../multi-tool-mcp/dist/worker.js", pluginRoot, serverName: "multi", sourceRoot }); + + assert.equal(copiedRoots.length, 1); + assert.deepEqual([first, second], [ + join(pluginRoot, "mcp", "multi", "dist", "cli.js"), + join(pluginRoot, "mcp", "multi", "dist", "worker.js"), + ]); +}); + +test("#given plugin-local MCP runtime #when rewriting cached manifest args #then keeps the cached plugin dist path", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin"); + const pluginRoot = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "0.1.0"); + const copiedRoots = []; + + await writeJson(join(sourceRoot, "package.json"), { + name: "@example/source-plugin", + version: "0.1.0", + }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/cached-plugin", + version: "0.1.0", + }); + await writeJson(join(pluginRoot, "dist", "cli.js"), { executable: true }); + + const rewrite = createCachedMcpRuntimeArgRewriter({ + copyDist: async (_source, target) => { + copiedRoots.push(target); + }, + }); + + const runtimeArg = await rewrite({ arg: "./dist/cli.js", pluginRoot, serverName: "omo", sourceRoot }); + + assert.equal(runtimeArg, join(pluginRoot, "dist", "cli.js")); + assert.equal(copiedRoots.length, 0); +}); + +test("#given structurally valid external MCP package without mcp suffix #when installing cached plugin #then runtime is copied into the plugin cache", async () => { + const repoRoot = await makeTempDir(); + const codexHome = await makeTempDir(); + const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin"); + const runtimePackageRoot = join(repoRoot, "packages", "language-tools"); + + await writeJson(join(runtimePackageRoot, "package.json"), { + name: "@example/language-tools", + version: "0.1.0", + type: "module", + bin: { "omo-language-tools": "./dist/cli.js" }, + }); + await writeJson(join(runtimePackageRoot, "dist", "cli.js"), { executable: true }); + await writeJson(join(sourceRoot, "package.json"), { + name: "@example/omo", + version: "0.1.0", + }); + await writeJson(join(sourceRoot, ".mcp.json"), { + mcpServers: { + language_tools: { + command: "node", + args: ["../../language-tools/dist/cli.js", "mcp", "../local-config.json"], + cwd: ".", + }, + }, + }); + + const result = await installCachedPlugin({ + codexHome, + marketplaceName: "sisyphuslabs", + name: "omo", + runCommand: async () => {}, + sourcePath: sourceRoot, + version: "0.1.0", + }); + + const cachedMcp = JSON.parse(await readFile(join(result.path, ".mcp.json"), "utf8")); + const copiedCli = join(result.path, "mcp", "language_tools", "dist", "cli.js"); + assert.deepEqual(cachedMcp.mcpServers.language_tools.args, [copiedCli, "mcp", join(sourceRoot, "..", "local-config.json")]); + assert.equal((await stat(copiedCli)).isFile(), true); +}); diff --git a/packages/omo-codex/scripts/install-test-fixtures.mjs b/packages/omo-codex/scripts/install-test-fixtures.mjs new file mode 100644 index 000000000..8e684dcaf --- /dev/null +++ b/packages/omo-codex/scripts/install-test-fixtures.mjs @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +export async function makeTempDir() { + return mkdtemp(join(tmpdir(), "omo-codex-install-")); +} + +export async function writeJson(path, value) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function writePlugin(root, name, version) { + const pluginRoot = join(root, "plugins", name); + await writePluginAt(pluginRoot, name, version); +} + +export async function writePluginAt(pluginRoot, name, version) { + await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true }); + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await mkdir(join(pluginRoot, "hooks"), { recursive: true }); + await mkdir(join(pluginRoot, "skills", name), { recursive: true }); + await mkdir(join(pluginRoot, "components", "ultrawork", "agents"), { recursive: true }); + await writeJson(join(pluginRoot, ".codex-plugin", "plugin.json"), { + name, + version, + description: `${name} test plugin`, + mcpServers: "./.mcp.json", + hooks: "./hooks/hooks.json", + skills: "./skills/", + }); + await writeJson(join(pluginRoot, ".mcp.json"), { + mcpServers: { + [name]: { + command: "node", + args: ["./dist/cli.js", "mcp"], + cwd: ".", + }, + }, + }); + await writeJson(join(pluginRoot, "hooks", "hooks.json"), { hooks: {} }); + await writeFile(join(pluginRoot, "skills", name, "SKILL.md"), "---\nname: test\n---\n"); + await writeFile(join(pluginRoot, "components", "ultrawork", "agents", "explorer.toml"), 'name = "explorer"\n'); + await writeFile(join(pluginRoot, "components", "ultrawork", "agents", "librarian.toml"), 'name = "librarian"\n'); + await writeFile(join(pluginRoot, "components", "ultrawork", "agents", "plan.toml"), 'name = "plan"\n'); + await writeJson(join(pluginRoot, "package.json"), { + name: `@example/${name}`, + version, + bin: { + [name]: "./dist/cli.js", + }, + scripts: { + build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"", + }, + dependencies: {}, + }); +} diff --git a/packages/omo-codex/scripts/sync-telemetry-component.mjs b/packages/omo-codex/scripts/sync-telemetry-component.mjs new file mode 100644 index 000000000..5f4cf0140 --- /dev/null +++ b/packages/omo-codex/scripts/sync-telemetry-component.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const TELEMETRY_SYNC_FILES = [ + "atomic-write.ts", + "data-path.ts", + "env-flags.ts", + "posthog-activity-state.ts", +]; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = dirname(SCRIPT_DIR); +const DEFAULT_SOURCE_DIR = join(PACKAGE_ROOT, "src", "telemetry"); +const DEFAULT_COMPONENT_DIR = join(PACKAGE_ROOT, "plugin", "components", "telemetry", "src"); + +export async function syncTelemetryComponent(options = {}) { + const sourceDir = resolve(options.sourceDir ?? DEFAULT_SOURCE_DIR); + const componentDir = resolve(options.componentDir ?? DEFAULT_COMPONENT_DIR); + const files = options.files ?? TELEMETRY_SYNC_FILES; + const check = options.check ?? false; + const changed = []; + + for (const fileName of files) { + const sourcePath = join(sourceDir, fileName); + const componentPath = join(componentDir, fileName); + const sourceText = await readFile(sourcePath, "utf8"); + const componentText = await readOptionalText(componentPath); + const nextText = toComponentSource(sourceText); + if (componentText === nextText) continue; + changed.push(fileName); + if (!check) { + await mkdir(dirname(componentPath), { recursive: true }); + await writeFile(componentPath, nextText); + } + } + + if (check && changed.length > 0) { + throw new Error(`telemetry component out of sync: ${changed.join(", ")}`); + } + + return { checked: check, changed }; +} + +function toComponentSource(sourceText) { + return sourceText + .replaceAll(/\bprocess\.env\.([A-Z0-9_]+)/g, 'process.env["$1"]') + .replaceAll(/from "(\.\/[^"]+)"/g, 'from "$1.js"'); +} + +async function readOptionalText(path) { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return null; + throw error; + } +} + +function isNodeError(error) { + return error instanceof Error && "code" in error; +} + +function parseArgs(args) { + const parsed = { + check: false, + sourceDir: DEFAULT_SOURCE_DIR, + componentDir: DEFAULT_COMPONENT_DIR, + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--check") { + parsed.check = true; + continue; + } + if (arg === "--source-dir") { + const value = args[index + 1]; + if (value === undefined) throw new Error("--source-dir requires a value"); + parsed.sourceDir = value; + index += 1; + continue; + } + if (arg === "--component-dir") { + const value = args[index + 1]; + if (value === undefined) throw new Error("--component-dir requires a value"); + parsed.componentDir = value; + index += 1; + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + return parsed; +} + +async function main() { + const result = await syncTelemetryComponent(parseArgs(process.argv.slice(2))); + if (result.changed.length === 0) { + console.log("telemetry component in sync"); + return; + } + console.log(`synced telemetry component: ${result.changed.join(", ")}`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/omo-codex/scripts/sync-telemetry-component.test.mjs b/packages/omo-codex/scripts/sync-telemetry-component.test.mjs new file mode 100644 index 000000000..1aed15da9 --- /dev/null +++ b/packages/omo-codex/scripts/sync-telemetry-component.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +const SCRIPT_PATH = new URL("./sync-telemetry-component.mjs", import.meta.url); + +async function makeTempDir() { + return mkdtemp(join(tmpdir(), "omo-codex-telemetry-sync-")); +} + +async function runSync(args) { + const { syncTelemetryComponent } = await import(SCRIPT_PATH); + return syncTelemetryComponent(args); +} + +test("#given stale telemetry component files #when sync runs #then pure package telemetry source rewrites the component copy", async () => { + // given + const root = await makeTempDir(); + const sourceDir = join(root, "source"); + const componentDir = join(root, "component"); + await mkdir(sourceDir); + await mkdir(componentDir); + await writeFile(join(sourceDir, "atomic-write.ts"), "export const source = true\n", { flush: true }); + await writeFile(join(componentDir, "atomic-write.ts"), "export const stale = true\n", { flush: true }); + + try { + // when + const result = await runSync({ + sourceDir, + componentDir, + files: ["atomic-write.ts"], + check: false, + }); + + // then + assert.deepEqual(result, { + checked: false, + changed: ["atomic-write.ts"], + }); + assert.equal(await readFile(join(componentDir, "atomic-write.ts"), "utf8"), "export const source = true\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("#given a missing pure telemetry source file #when sync runs #then it fails with the missing source path", async () => { + // given + const root = await makeTempDir(); + const sourceDir = join(root, "source"); + const componentDir = join(root, "component"); + await mkdir(componentDir); + await writeFile(join(componentDir, "atomic-write.ts"), "export const stale = true\n", { flush: true }); + + try { + // when / then + await assert.rejects( + runSync({ + sourceDir, + componentDir, + files: ["atomic-write.ts"], + check: false, + }), + (error) => error instanceof Error && error.message.includes(join(sourceDir, "atomic-write.ts")), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +});