From ac4266ec1fad6a63ccdf07c4186a19c21b1c2e68 Mon Sep 17 00:00:00 2001 From: Travis Hinton Date: Fri, 1 May 2026 16:13:27 -0600 Subject: [PATCH] Support codex-app Linux install layout --- packages/installer/src/commands/install.ts | 16 ++-- packages/installer/src/platform.ts | 73 ++++++++++++++++--- packages/installer/test/platform.test.ts | 42 ++++++++++- .../installer/test/tweak-commands.test.ts | 20 +++++ 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/packages/installer/src/commands/install.ts b/packages/installer/src/commands/install.ts index 396e352..9ad631d 100644 --- a/packages/installer/src/commands/install.ts +++ b/packages/installer/src/commands/install.ts @@ -236,14 +236,16 @@ function patchCodexWindowServices(appDir: string, originalMain: string): void { throw new Error("Codex window services hook point not found"); } -function findCodexMainCandidates(appDir: string, originalMain: string): string[] { +export function findCodexMainCandidates(appDir: string, originalMain: string): string[] { const out = [resolve(appDir, originalMain)]; - const buildDir = resolve(appDir, ".vite", "build"); - try { - for (const name of readdirSync(buildDir)) { - if (/^main-.*\.js$/.test(name)) out.push(resolve(buildDir, name)); - } - } catch {} + const originalMainDir = resolve(appDir, originalMain, ".."); + for (const buildDir of [originalMainDir, resolve(appDir, ".vite", "build")]) { + try { + for (const name of readdirSync(buildDir)) { + if (/^main-.*\.js$/.test(name)) out.push(resolve(buildDir, name)); + } + } catch {} + } return [...new Set(out)].filter((p) => existsSync(p)); } diff --git a/packages/installer/src/platform.ts b/packages/installer/src/platform.ts index 53846a7..2b4e662 100644 --- a/packages/installer/src/platform.ts +++ b/packages/installer/src/platform.ts @@ -1,6 +1,6 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync, statSync } from "node:fs"; import { homedir, platform } from "node:os"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { readPlist } from "./plist.js"; export type Platform = "darwin" | "win32" | "linux"; @@ -167,16 +167,24 @@ function locateWin(override?: string): CodexInstall { } function locateLinux(override?: string): CodexInstall { - // Codex isn't yet shipped on Linux at time of writing; assume an Electron-style - // unpacked install or a deb/rpm in /opt. + // Linux builds are distributed by community ports today. Support unpacked + // Electron installs from deb/rpm packages as well as user-local symlinked + // installs used by am-will/codex-app. const candidates = [ override, + "/usr/lib/codex-desktop", + "/opt/codex-desktop/current", + "/opt/codex-desktop", "/opt/Codex", "/opt/codex", + join(homedir(), ".local", "opt", "codex-desktop", "current"), + join(homedir(), ".local", "opt", "codex-desktop"), + join(homedir(), ".local", "share", "codex-desktop", "current"), + join(homedir(), ".local", "share", "codex-desktop"), join(homedir(), ".local", "share", "Codex"), ].filter(Boolean) as string[]; - const appRoot = candidates.find((p) => existsSync(join(p, "resources", "app.asar"))); - if (!appRoot) { + const install = candidates.map(resolveLinuxInstall).find((p): p is LinuxInstallCandidate => p !== null); + if (!install) { throw new Error( `[!] Codex App Not Found\n\n` + `Ensure Codex is installed in a supported Linux location.\n` + @@ -184,17 +192,64 @@ function locateLinux(override?: string): CodexInstall { `If Codex is somewhere else, rerun with --app pointing at its install folder.`, ); } - const resourcesDir = join(appRoot, "resources"); + const { appRoot, resourcesDir, executable } = install; return { appRoot, resourcesDir, asarPath: join(resourcesDir, "app.asar"), metaPath: null, - electronBinary: join(appRoot, "codex"), - executable: join(appRoot, "codex"), + electronBinary: executable, + executable, appName: "Codex", bundleId: null, channel: "stable", platform: "linux", }; } + +interface LinuxInstallCandidate { + appRoot: string; + resourcesDir: string; + executable: string; +} + +function resolveLinuxInstall(candidate: string): LinuxInstallCandidate | null { + let resolved = candidate; + try { + resolved = realpathSync(candidate); + } catch { + // Keep the original path so the directory checks below can fail normally. + } + + const roots: string[] = []; + if (existsSync(resolved)) { + try { + const stat = statSync(resolved); + if (stat.isDirectory()) { + roots.push(resolved); + if (basename(resolved) === "resources") roots.push(resolve(resolved, "..")); + } else if (stat.isFile()) { + roots.push(resolve(resolved, "..")); + } + } catch {} + } + roots.push(resolved); + + for (const root of [...new Set(roots)]) { + const resourcesDir = join(root, "resources"); + if (!existsSync(join(resourcesDir, "app.asar"))) continue; + const executable = findLinuxExecutable(root); + if (!executable) continue; + return { appRoot: root, resourcesDir, executable }; + } + return null; +} + +function findLinuxExecutable(appRoot: string): string | null { + const candidates = [ + "Codex", + "codex-desktop", + "codex", + ].map((name) => join(appRoot, name)); + return candidates.find((p) => existsSync(p)) ?? null; +} diff --git a/packages/installer/test/platform.test.ts b/packages/installer/test/platform.test.ts index 00e747a..f3d8924 100644 --- a/packages/installer/test/platform.test.ts +++ b/packages/installer/test/platform.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -41,3 +41,43 @@ test("locateCodex reads beta bundle metadata from override path on macOS", { ski rmSync(root, { recursive: true, force: true }); } }); + +test("locateCodex supports am-will codex-app Linux install directory", { skip: process.platform !== "linux" }, () => { + const root = mkdtempSync(join(tmpdir(), "codexpp-platform-")); + try { + const app = join(root, "codex-desktop"); + mkdirSync(join(app, "resources"), { recursive: true }); + writeFileSync(join(app, "resources", "app.asar"), ""); + writeFileSync(join(app, "Codex"), ""); + + const codex = locateCodex(app); + assert.equal(codex.appRoot, app); + assert.equal(codex.resourcesDir, join(app, "resources")); + assert.equal(codex.asarPath, join(app, "resources", "app.asar")); + assert.equal(codex.electronBinary, join(app, "Codex")); + assert.equal(codex.executable, join(app, "Codex")); + assert.equal(codex.platform, "linux"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("locateCodex accepts a Linux launcher symlink override", { skip: process.platform !== "linux" }, () => { + const root = mkdtempSync(join(tmpdir(), "codexpp-platform-")); + try { + const app = join(root, "codex-desktop"); + const bin = join(root, "bin"); + mkdirSync(join(app, "resources"), { recursive: true }); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(app, "resources", "app.asar"), ""); + writeFileSync(join(app, "Codex"), ""); + symlinkSync(join(app, "Codex"), join(bin, "codex-desktop")); + + const codex = locateCodex(join(bin, "codex-desktop")); + assert.equal(codex.appRoot, app); + assert.equal(codex.resourcesDir, join(app, "resources")); + assert.equal(codex.electronBinary, join(app, "Codex")); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/installer/test/tweak-commands.test.ts b/packages/installer/test/tweak-commands.test.ts index cf98b4d..2c50513 100644 --- a/packages/installer/test/tweak-commands.test.ts +++ b/packages/installer/test/tweak-commands.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -10,6 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { buildCliFailureIssueUrl, buildPatchFailureIssueUrl } from "../src/alerts"; +import { findCodexMainCandidates } from "../src/commands/install"; import { createTweak } from "../src/commands/create-tweak"; import { devTweak } from "../src/commands/dev-tweak"; import { safeMode } from "../src/commands/safe-mode"; @@ -265,6 +267,24 @@ test("window services patch ignores unrelated buildFlavor factories", () => { assert.equal(patchCodexWindowServicesSource(source), null); }); +test("main candidate discovery supports nested recovered app bundle layout", () => { + withTempDir((root) => { + const buildDir = join(root, "recovered", "app-asar-extracted", ".vite", "build"); + mkdirSync(buildDir, { recursive: true }); + const bootstrap = join(buildDir, "bootstrap.js"); + const main = join(buildDir, "main-SLemWUtC.js"); + writeFileSync(bootstrap, ""); + writeFileSync(main, ""); + + const candidates = findCodexMainCandidates( + root, + "recovered/app-asar-extracted/.vite/build/bootstrap.js", + ); + + assert.deepEqual(candidates, [bootstrap, main]); + }); +}); + test("patch failure report URL includes a prefilled GitHub issue", () => { const url = new URL(buildPatchFailureIssueUrl("Codex window services hook point not found"));