diff --git a/src/cli.ts b/src/cli.ts index d55bd0562..285735b93 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,5 @@ #!/usr/bin/env bun -import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { execFileSync, spawn } from "node:child_process"; import { restoreNativeCodex } from "./codex-inject"; import { loadConfig, readPid, removePid, writePid } from "./config"; import { serviceCommand, stopServiceIfInstalled } from "./service"; @@ -38,11 +37,10 @@ async function syncModelsToCodex(port?: number) { const p = port ?? config.port ?? 10100; let catalogPath: string | null | undefined; try { - const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog"); - const cat = await syncCatalogModels(config); - catalogPath = existsSync(cat.path) ? cat.path : null; + const { refreshCodexModelCatalog } = await import("./codex-refresh"); + const cat = await refreshCodexModelCatalog(config); + catalogPath = cat.catalogExists ? cat.path : null; if (cat.added > 0) { - invalidateCodexModelsCache(); console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`); } else if (catalogPath === null) { console.error("catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog."); @@ -56,7 +54,7 @@ async function syncModelsToCodex(port?: number) { return result; } -async function handleStart() { +async function handleStart(options: { block?: boolean } = {}) { const existingPid = readPid(); if (existingPid) { console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`); @@ -91,6 +89,10 @@ async function handleStart() { await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start await syncModelsToCodex(port).catch(() => {}); + if (options.block ?? true) { + setInterval(() => {}, 60_000); + await new Promise(() => {}); + } } function killProxy(pid: number): void { @@ -204,7 +206,12 @@ switch (command) { const guiUrl = `http://localhost:${config.port}`; if (!cfg.readPid()) { console.log("Proxy not running. Starting..."); - await handleStart(); + const child = spawn(process.execPath, [process.argv[1], "start"], { + detached: true, + stdio: "ignore", + env: process.env, + }); + child.unref(); await new Promise(r => setTimeout(r, 1000)); } console.log(`Opening ${guiUrl}`); diff --git a/src/codex-refresh.ts b/src/codex-refresh.ts new file mode 100644 index 000000000..527972e4e --- /dev/null +++ b/src/codex-refresh.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from "node:fs"; +import { invalidateCodexModelsCache, syncCatalogModels } from "./codex-catalog"; +import { CODEX_MODELS_CACHE_PATH } from "./codex-paths"; +import { atomicWriteFile } from "./config"; +import type { OcxConfig } from "./types"; + +export interface CodexCatalogRefreshResult { + added: number; + path: string; + catalogExists: boolean; + cacheSynced: boolean; +} + +interface RefreshDeps { + syncCatalogModels: typeof syncCatalogModels; + invalidateCodexModelsCache: typeof invalidateCodexModelsCache; + syncCodexModelsCacheFromCatalog: typeof syncCodexModelsCacheFromCatalog; + existsSync: typeof existsSync; +} + +const defaultDeps: RefreshDeps = { + syncCatalogModels, + invalidateCodexModelsCache, + syncCodexModelsCacheFromCatalog, + existsSync, +}; + +export function syncCodexModelsCacheFromCatalog(catalogPath: string): void { + const content = readFileSync(catalogPath, "utf8"); + atomicWriteFile(CODEX_MODELS_CACHE_PATH, content); +} + +/** + * Rebuild Codex's on-disk model catalog and keep Codex's models cache aligned + * when a catalog file exists. Codex Desktop can read models_cache.json directly, + * so deleting a stale cache is not enough: the cache must be replaced with the + * same catalog content the CLI debug path reads. + */ +export async function refreshCodexModelCatalog( + config: OcxConfig, + deps: RefreshDeps = defaultDeps, +): Promise { + const result = await deps.syncCatalogModels(config); + const catalogExists = deps.existsSync(result.path); + if (!catalogExists) return { ...result, catalogExists, cacheSynced: false }; + deps.invalidateCodexModelsCache(); + deps.syncCodexModelsCacheFromCatalog(result.path); + return { ...result, catalogExists, cacheSynced: true }; +} diff --git a/src/server.ts b/src/server.ts index b33f426ea..116d5ab12 100644 --- a/src/server.ts +++ b/src/server.ts @@ -362,6 +362,15 @@ function jsonResponse(data: unknown, status = 200): Response { } async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise { + async function refreshCodexCatalogBestEffort(): Promise { + try { + const { refreshCodexModelCatalog } = await import("./codex-refresh"); + await refreshCodexModelCatalog(config); + } catch { + /* catalog absent */ + } + } + if (url.pathname === "/api/config" && req.method === "GET") { const safeConfig = JSON.parse(JSON.stringify(config)); for (const prov of Object.values(safeConfig.providers as Record)) { @@ -406,6 +415,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P config.providers[name] = prov; if (body.setDefault) config.defaultProvider = name; save(config); + await refreshCodexCatalogBestEffort(); return jsonResponse({ success: true, name }); } @@ -416,11 +426,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P delete config.providers[name]; save(config); // Drop its models from Codex's catalog immediately (re-sync + cache bust) so removal is live. - try { - const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog"); - await syncCatalogModels(config); - invalidateCodexModelsCache(); - } catch { /* catalog absent */ } + await refreshCodexCatalogBestEffort(); return jsonResponse({ success: true }); } @@ -442,11 +448,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P config.disabledModels = disabled; const { saveConfig: save } = await import("./config"); save(config); - try { - const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog"); - await syncCatalogModels(config); - invalidateCodexModelsCache(); - } catch { /* catalog absent */ } + await refreshCodexCatalogBestEffort(); return jsonResponse({ ok: true, disabled }); } @@ -487,11 +489,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P config.subagentModels = chosen; const { saveConfig: save } = await import("./config"); save(config); - try { - const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog"); - await syncCatalogModels(config); - invalidateCodexModelsCache(); - } catch { /* catalog absent */ } + await refreshCodexCatalogBestEffort(); return jsonResponse({ ok: true, applied: chosen }); } diff --git a/tests/codex-refresh.test.ts b/tests/codex-refresh.test.ts new file mode 100644 index 000000000..31f7aecad --- /dev/null +++ b/tests/codex-refresh.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { refreshCodexModelCatalog } from "../src/codex-refresh"; +import type { OcxConfig } from "../src/types"; + +const config = { + port: 10100, + defaultProvider: "openai", + providers: {}, +} as OcxConfig; + +describe("Codex catalog refresh", () => { + test("replaces Codex's models cache whenever the materialized catalog exists", async () => { + let invalidated = 0; + let syncedFrom: string | null = null; + const result = await refreshCodexModelCatalog(config, { + syncCatalogModels: async () => ({ added: 0, path: "/tmp/opencodex-catalog.json" }), + invalidateCodexModelsCache: () => { invalidated += 1; }, + syncCodexModelsCacheFromCatalog: (path) => { syncedFrom = path; }, + existsSync: () => true, + }); + + expect(result).toEqual({ + added: 0, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + cacheSynced: true, + }); + expect(invalidated).toBe(1); + expect(syncedFrom).toBe("/tmp/opencodex-catalog.json"); + }); + + test("does not touch the cache when no Codex catalog can be materialized", async () => { + let invalidated = 0; + let synced = false; + const result = await refreshCodexModelCatalog(config, { + syncCatalogModels: async () => ({ added: 0, path: "/tmp/missing-catalog.json" }), + invalidateCodexModelsCache: () => { invalidated += 1; }, + syncCodexModelsCacheFromCatalog: () => { synced = true; }, + existsSync: () => false, + }); + + expect(result.catalogExists).toBe(false); + expect(result.cacheSynced).toBe(false); + expect(invalidated).toBe(0); + expect(synced).toBe(false); + }); +});