Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions packages/installer/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
73 changes: 64 additions & 9 deletions packages/installer/src/platform.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -167,34 +167,89 @@ 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` +
`Tried:\n ${candidates.join("\n ")}\n\n` +
`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;
}
42 changes: 41 additions & 1 deletion packages/installer/test/platform.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
});
20 changes: 20 additions & 0 deletions packages/installer/test/tweak-commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
Expand All @@ -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";
Expand Down Expand Up @@ -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"));

Expand Down