diff --git a/bin/oh-my-opencode.js b/bin/oh-my-opencode.js index 1a712c9c6..b15583085 100755 --- a/bin/oh-my-opencode.js +++ b/bin/oh-my-opencode.js @@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { basename } from "node:path"; +import { fileURLToPath } from "node:url"; import { getPlatformPackageCandidates, getBinaryPath, @@ -90,6 +91,10 @@ function getWrapperPackageName() { } } +function getWrapperPackageRoot() { + return fileURLToPath(new URL("..", import.meta.url)); +} + /** * Determine which bin name the user invoked us with (oh-my-opencode, oh-my-openagent, omo, lazycodex). * Propagated to the compiled CLI binary via OMO_INVOCATION_NAME so it can route accordingly @@ -155,6 +160,7 @@ function main() { const childEnv = { ...process.env, OMO_INVOCATION_NAME: invocationName, + OMO_WRAPPER_PACKAGE_ROOT: getWrapperPackageRoot(), }; for (let index = 0; index < resolvedBinaries.length; index += 1) { diff --git a/bin/oh-my-opencode.test.ts b/bin/oh-my-opencode.test.ts index 692ee7434..8de969372 100644 --- a/bin/oh-my-opencode.test.ts +++ b/bin/oh-my-opencode.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmod, cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { chmod, cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -33,6 +33,7 @@ describe("lazycodex bin wrapper", () => { // #then expect(result.status).toBe(23); expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex"); + expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root)); expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([ "install", "--no-tui", @@ -57,6 +58,7 @@ describe("lazycodex bin wrapper", () => { // #then expect(result.status).toBe(23); expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex"); + expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root)); expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([ "install", "--no-tui", @@ -81,6 +83,7 @@ describe("lazycodex bin wrapper", () => { // #then expect(result.status).toBe(23); expect((await readFile(join(fixture.captureDir, "env"), "utf8")).trim()).toBe("lazycodex"); + expect(await canonicalizePackageRootCapture(fixture)).toBe(await realpath(fixture.root)); expect((await readFile(join(fixture.captureDir, "args"), "utf8")).trim().split("\n")).toEqual([ "install", "--no-tui", @@ -130,10 +133,15 @@ async function createLazyCodexFixture(options: { packageName?: string; wrapperFi captureDir, fakeBinDir, lazycodexBin: join(binDir, "lazycodex"), + root, wrapperBin, }; } +async function canonicalizePackageRootCapture(fixture: { readonly captureDir: string }): Promise { + return realpath((await readFile(join(fixture.captureDir, "wrapper-root"), "utf8")).trim()); +} + async function writePlatformPackages(root: string): Promise { const packages = getPlatformPackageCandidates({ platform: process.platform, @@ -149,6 +157,7 @@ async function writePlatformPackages(root: string): Promise { [ "#!/bin/sh", "printf '%s\\n' \"$OMO_INVOCATION_NAME\" > \"$CAPTURE_DIR/env\"", + "printf '%s\\n' \"$OMO_WRAPPER_PACKAGE_ROOT\" > \"$CAPTURE_DIR/wrapper-root\"", "printf '%s\\n' \"$@\" > \"$CAPTURE_DIR/args\"", "exit 23", "", diff --git a/src/cli/install-codex/install-codex.test.ts b/src/cli/install-codex/install-codex.test.ts index 4b4afb193..5b6967659 100644 --- a/src/cli/install-codex/install-codex.test.ts +++ b/src/cli/install-codex/install-codex.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test" import { mkdir, mkdtemp, readdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { findRepoRootFromImporter, resolveCodexInstallerBinDir, runCodexInstaller } from "./install-codex" +import { findRepoRoot, findRepoRootFromImporter, resolveCodexInstallerBinDir, runCodexInstaller } from "./install-codex" const EXPECTED_OMO_COMPONENT_BINS = [ { name: "omo", target: join("components", "ulw-loop", "dist", "cli.js") }, @@ -42,6 +42,23 @@ describe("install-codex", () => { expect(repoRoot).toBe(wrapperRoot) }) + test("#given wrapper root env #when resolving vendored repo root #then prefers wrapper package root", async () => { + // given + const platformPackageRoot = await mkdtemp(join(tmpdir(), "omo-codex-platform-package-")) + const wrapperRoot = await mkdtemp(join(tmpdir(), "omo-codex-wrapper-package-")) + await mkdir(join(wrapperRoot, "packages", "omo-codex", "plugin", ".codex-plugin"), { recursive: true }) + await writeFile(join(wrapperRoot, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json"), "{}") + + // when + const repoRoot = findRepoRoot({ + importerDir: join(platformPackageRoot, "bin"), + env: { OMO_WRAPPER_PACKAGE_ROOT: wrapperRoot }, + }) + + // then + expect(repoRoot).toBe(wrapperRoot) + }) + test("#given default CODEX_HOME #when resolving installer bin dir without override #then preserves user local bin precedence", () => { // given const homeDir = join(tmpdir(), "omo-codex-home-default") diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 9833c1fae..4ea48ef57 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -14,7 +14,7 @@ import type { CodexInstallOptions, CodexInstallResult, CodexMarketplaceSource, I const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise { - const repoRoot = resolve(options.repoRoot ?? findRepoRootFromImporter(import.meta.dir)) + const repoRoot = resolve(options.repoRoot ?? findRepoRoot({ importerDir: import.meta.dir, env: process.env })) const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex")) const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env }) const runCommand = options.runCommand ?? defaultRunCommand @@ -199,11 +199,9 @@ function codexMarketplaceSource(marketplaceRoot: string): CodexMarketplaceSource export function findRepoRootFromImporter(importerDir: string): string { let current = importerDir for (let depth = 0; depth <= 5; depth += 1) { - const pluginManifestPath = join(current, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json") - if (existsSyncLike(pluginManifestPath)) return current + if (isRepoRootWithCodexPlugin(current)) return current for (const wrapperPackageRoot of [join(current, "node_modules", "oh-my-openagent"), join(current, "oh-my-openagent")]) { - const wrapperPluginManifestPath = join(wrapperPackageRoot, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json") - if (existsSyncLike(wrapperPluginManifestPath)) return wrapperPackageRoot + if (isRepoRootWithCodexPlugin(wrapperPackageRoot)) return wrapperPackageRoot } current = resolve(current, "..") } @@ -212,6 +210,22 @@ export function findRepoRootFromImporter(importerDir: string): string { ) } +export function findRepoRoot(input: { + readonly importerDir: string + readonly env?: { readonly [key: string]: string | undefined } +}): string { + const wrapperPackageRoot = input.env?.OMO_WRAPPER_PACKAGE_ROOT + if (wrapperPackageRoot !== undefined && wrapperPackageRoot.trim().length > 0) { + const resolvedWrapperPackageRoot = resolve(wrapperPackageRoot) + if (isRepoRootWithCodexPlugin(resolvedWrapperPackageRoot)) return resolvedWrapperPackageRoot + } + return findRepoRootFromImporter(input.importerDir) +} + +function isRepoRootWithCodexPlugin(repoRoot: string): boolean { + return existsSyncLike(join(repoRoot, "packages", "omo-codex", "plugin", ".codex-plugin", "plugin.json")) +} + function existsSyncLike(path: string): boolean { return existsSync(path) }