diff --git a/README.ko.md b/README.ko.md index 50186ec21..c4ccd4b97 100644 --- a/README.ko.md +++ b/README.ko.md @@ -36,12 +36,15 @@ Codex CLI / App / SDK ──/v1/responses──▶ opencodex ──▶ Any provi # 설치 npm install -g @bitkyc08/opencodex # 또는: bun install -g @bitkyc08/opencodex -# 대화형 설정 (config 작성 + Codex에 자동 주입) +# 대화형 설정 (config 작성 + Codex 주입 + 자동 시작 shim 설치 선택) ocx init # 프록시 시작 ocx start +# init에서 건너뛰었다면 나중에 온디맨드 자동 시작 shim 설치 +ocx codex-shim install + # Codex를 평소처럼 사용하세요 — opencodex를 통해 라우팅됩니다 codex "Write a hello world in Rust" ``` @@ -122,15 +125,17 @@ codex -m "xai/grok-4" "이 PR을 리뷰해 줘" ```bash ocx init # 대화형 설정 -ocx start [--port 10100] # 프록시 시작 +ocx start [--port 10100] # 프록시 시작; 포트가 사용 중이면 빈 포트로 자동 전환 ocx stop # 프록시 중지 + Codex 원래 설정 복원 ocx restore # 중지 없이 복원 (별칭: ocx eject) +ocx uninstall # service/shim/config 제거 + Codex 원본 복원 +ocx ensure # 필요 시 시작 + Codex config/cache 갱신 ocx sync # 모델 갱신 + Codex에 재주입 ocx status # 프록시 실행 중인지 확인 ocx login # OAuth 로그인 ocx logout # 저장된 로그인 정보 삭제 ocx gui # 웹 대시보드 열기 -ocx codex-shim install # codex 실행 시 프록시 자동 시작 +ocx codex-shim install # codex 실행 시 `ocx ensure` 실행 ocx service # 백그라운드 서비스 (launchd/systemd/schtasks) ocx update # opencodex를 최신 버전으로 업데이트 ``` @@ -142,12 +147,26 @@ opencodex에는 프록시를 자동 시작하는 두 가지 방법이 있습니 | | `ocx service install` | `ocx codex-shim install` | |---|---|---| | **방식** | OS 서비스 관리자 (launchd / systemd / schtasks) | `codex` 바이너리를 래퍼 스크립트로 교체 | -| **시점** | 로그인 후 항상 실행 | 온디맨드 — `codex` 실행 시에만 시작 | +| **시점** | 로그인 후 항상 실행 | 온디맨드 — `codex` 실행 시 `ocx ensure` 실행 | | **재시작** | 크래시 시 자동 재시작 | `codex` 호출마다 한 번 시작 | | **Codex 업데이트** | 영향 없음 | `ocx codex-shim install` 또는 `ocx update` 시 복구 | | **제거** | `ocx service uninstall` | `ocx codex-shim uninstall` | 항상 프록시를 켜두려면 **service** (개발 머신 권장), 가볍게 온디맨드로 쓰려면 **shim**을 사용하세요. +shim 자동 시작은 기본으로 켜져 있으며 GUI 대시보드에서 끌 수 있습니다. 설정된 프록시 포트가 이미 사용 +중이면 `ocx start`가 자동으로 다른 빈 로컬 포트를 고르고 Codex 설정도 그 포트로 갱신합니다. + +### 삭제 + +npm/bun 패키지를 지우기 전에 로컬 상태를 먼저 정리하세요: + +```bash +ocx uninstall +npm uninstall -g @bitkyc08/opencodex # 또는: bun remove -g @bitkyc08/opencodex +``` + +`ocx uninstall`은 프록시 중지, 설치된 service 제거, Codex shim 제거, Codex config/catalog/history +원복, `~/.opencodex` 삭제를 처리합니다. ## 설정 diff --git a/README.md b/README.md index 95ea68c93..7a91eb2eb 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,15 @@ Requires [Bun](https://bun.sh) 1.1+. All three platforms work natively (no WSL n # Install npm install -g @bitkyc08/opencodex # or: bun install -g @bitkyc08/opencodex -# Interactive setup (writes config + injects into Codex) +# Interactive setup (writes config, injects into Codex, and offers autostart shim install) ocx init # Start the proxy ocx start +# If you skipped it during init, install the on-demand autostart shim later +ocx codex-shim install + # Use Codex normally — it now routes through opencodex codex "Write a hello world in Rust" ``` @@ -144,11 +147,13 @@ Plus DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging ```bash ocx init # interactive setup -ocx start [--port 10100] # start the proxy +ocx start [--port 10100] # start the proxy; falls back to a free port if busy ocx stop # stop + restore native Codex ocx restore # restore without stopping (alias: ocx eject) +ocx uninstall # remove service/shim/config and restore native Codex +ocx ensure # start if needed + refresh Codex config/cache ocx sync # refresh models + re-inject into Codex -ocx codex-shim install # auto-start the proxy whenever `codex` is launched +ocx codex-shim install # run `ocx ensure` whenever `codex` is launched ocx status # is the proxy running? ocx login # OAuth login ocx logout # remove a stored login @@ -164,13 +169,27 @@ opencodex has two ways to auto-start the proxy: | | `ocx service install` | `ocx codex-shim install` | |---|---|---| | **How** | OS service manager (launchd / systemd / schtasks) | Replaces the `codex` binary with a wrapper script | -| **When** | Always running after login | On-demand — starts only when `codex` is launched | +| **When** | Always running after login | On-demand — runs `ocx ensure` when `codex` is launched | | **Restart** | Auto-restarts on crash | Starts once per `codex` invocation | | **Codex updates** | Unaffected | Repairs on next `ocx codex-shim install` or `ocx update` | | **Remove** | `ocx service uninstall` | `ocx codex-shim uninstall` | Use the **service** for always-on proxy (recommended for development machines). Use the **shim** for -lightweight, on-demand proxy startup without a background daemon. +lightweight, on-demand proxy startup without a background daemon. Shim autostart is enabled by default +and can be disabled from the GUI dashboard. If the configured proxy port is already busy, `ocx start` +automatically picks another free local port and updates Codex to use it. + +### Uninstall + +Before removing the npm/bun package, clean up local state: + +```bash +ocx uninstall +npm uninstall -g @bitkyc08/opencodex # or: bun remove -g @bitkyc08/opencodex +``` + +`ocx uninstall` stops the proxy, removes any installed service, removes the Codex shim, restores +native Codex config/catalog/history, and deletes `~/.opencodex`. ## Configuration diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ff99c43ae..d4c2656c2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -39,6 +39,8 @@ export const en = { "dash.stop": "Stop Proxy", "dash.stopConfirm": "Stop the proxy and restore native Codex?", "dash.stopping": "Stopping…", + "dash.codexAutoStart": "Start opencodex with Codex", + "dash.codexAutoStartHint": "When enabled, the Codex shim runs ocx ensure before every Codex CLI/App launch. Disable this to leave Codex untouched.", // providers "prov.subtitle": "Configure the upstream providers opencodex routes into Codex. Log in with an account, add a provider, or edit the raw config.", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 714ea525f..8daed8382 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -39,6 +39,8 @@ export const ko: Record = { "dash.stop": "프록시 중지", "dash.stopConfirm": "프록시를 중지하고 Codex 원본 설정을 복원할까요?", "dash.stopping": "중지 중…", + "dash.codexAutoStart": "Codex 실행 시 opencodex 시작", + "dash.codexAutoStartHint": "켜져 있으면 Codex CLI/App 실행 전 shim이 ocx ensure를 실행합니다. 끄면 Codex를 건드리지 않습니다.", // providers "prov.subtitle": "opencodex가 Codex로 라우팅하는 업스트림 프로바이더를 설정합니다. 계정으로 로그인하거나, 프로바이더를 추가하거나, 원본 설정을 편집하세요.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8b8db5853..58f0c77ab 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -39,6 +39,8 @@ export const zh: Record = { "dash.stop": "停止代理", "dash.stopConfirm": "停止代理并恢复原生 Codex 配置?", "dash.stopping": "正在停止…", + "dash.codexAutoStart": "随 Codex 启动 opencodex", + "dash.codexAutoStartHint": "启用后,Codex shim 会在每次 Codex CLI/App 启动前运行 ocx ensure。关闭后不会改动 Codex。", // providers "prov.subtitle": "配置 opencodex 路由到 Codex 的上游提供方。使用账户登录、添加提供方,或编辑原始配置。", diff --git a/gui/src/pages/Dashboard.tsx b/gui/src/pages/Dashboard.tsx index 0605dae16..1d62885e2 100644 --- a/gui/src/pages/Dashboard.tsx +++ b/gui/src/pages/Dashboard.tsx @@ -5,24 +5,29 @@ import { useT, Trans } from "../i18n"; interface HealthData { status: string; version: string; uptime: number } interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean } interface ModelInfo { id: string; provider: string; owned_by?: string } +interface SettingsData { codexAutoStart: boolean; port: number; hostname: string } export default function Dashboard({ apiBase }: { apiBase: string }) { const t = useT(); const [health, setHealth] = useState(null); const [providers, setProviders] = useState([]); const [models, setModels] = useState([]); + const [settings, setSettings] = useState(null); const [modelsLoading, setModelsLoading] = useState(false); + const [settingsSaving, setSettingsSaving] = useState(false); const [error, setError] = useState(false); useEffect(() => { const fetchData = async () => { try { - const [hRes, pRes] = await Promise.all([ + const [hRes, pRes, sRes] = await Promise.all([ fetch(`${apiBase}/healthz`), fetch(`${apiBase}/api/providers`), + fetch(`${apiBase}/api/settings`), ]); setHealth(await hRes.json()); setProviders(await pRes.json()); + setSettings(await sRes.json()); setError(false); } catch { setError(true); @@ -61,6 +66,27 @@ export default function Dashboard({ apiBase }: { apiBase: string }) { const online = health?.status === "ok"; + const toggleCodexAutoStart = async () => { + if (!settings || settingsSaving) return; + const next = !settings.codexAutoStart; + setSettingsSaving(true); + setSettings({ ...settings, codexAutoStart: next }); + try { + const res = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ codexAutoStart: next }), + }); + if (!res.ok) throw new Error("save failed"); + const data = await res.json(); + setSettings(prev => prev ? { ...prev, codexAutoStart: data.codexAutoStart } : prev); + } catch { + setSettings(prev => prev ? { ...prev, codexAutoStart: !next } : prev); + } finally { + setSettingsSaving(false); + } + }; + return ( <>

{t("nav.dashboard")}

@@ -78,6 +104,24 @@ export default function Dashboard({ apiBase }: { apiBase: string }) {
{t("dash.providers")}
{providers.length}
+
+
+
+
{t("dash.codexAutoStart")}
+
{t("dash.codexAutoStartHint")}
+
+ +
+
+
{t("dash.activeProviders")} {providers.length}
{providers.length === 0 ? (
diff --git a/src/cli.ts b/src/cli.ts index b9b0ce875..8fd256dbf 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,10 @@ #!/usr/bin/env bun import { execFileSync, spawn } from "node:child_process"; +import { rmSync } from "node:fs"; import { restoreNativeCodex } from "./codex-inject"; -import { loadConfig, readPid, removePid, writePid } from "./config"; -import { serviceCommand, stopServiceIfInstalled } from "./service"; +import { codexAutoStartEnabled, getConfigDir, loadConfig, readPid, removePid, saveConfig, writePid } from "./config"; +import { findAvailablePort } from "./ports"; +import { serviceCommand, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service"; import { startServer } from "./server"; import { maybeShowStarPrompt } from "./star-prompt"; @@ -17,8 +19,10 @@ Usage: ocx start [--port ] Start the proxy server (auto-syncs models to Codex) ocx stop Stop the proxy AND restore native Codex (plain codex works again) ocx restore Restore native Codex without stopping (alias: eject) + ocx uninstall Remove service/shim/config and restore native Codex ocx service Run as a background service (install|start|stop|status|uninstall) ocx codex-shim Auto-start proxy when \`codex\` launches (install|status|uninstall) + ocx ensure Ensure the proxy is running and Codex config/cache are current ocx sync Fetch models from providers and inject into Codex config ocx sync-cache Refresh Codex's model cache from the active catalog ocx status Check proxy server status @@ -56,23 +60,74 @@ async function syncModelsToCodex(port?: number) { return result; } -async function handleStart(options: { block?: boolean } = {}) { - const existingPid = readPid(); - if (existingPid) { - console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`); +function parsePortOption(): number | undefined { + const portIdx = args.indexOf("--port"); + if (portIdx === -1) return undefined; + const value = args[portIdx + 1]; + const port = value ? parseInt(value, 10) : NaN; + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error("Invalid port number"); process.exit(1); } + return port; +} - let port: number | undefined; - const portIdx = args.indexOf("--port"); - if (portIdx !== -1 && args[portIdx + 1]) { - port = parseInt(args[portIdx + 1], 10); - if (isNaN(port)) { - console.error("Invalid port number"); +function healthHost(hostname?: string): string { + return !hostname || hostname === "0.0.0.0" || hostname === "::" ? "127.0.0.1" : hostname; +} + +async function proxyHealthy(port?: number): Promise { + const config = loadConfig(); + const p = port ?? config.port ?? 10100; + try { + const res = await fetch(`http://${healthHost(config.hostname)}:${p}/healthz`, { + signal: AbortSignal.timeout(750), + }); + return res.ok; + } catch { + return false; + } +} + +async function waitForProxy(timeoutMs = 8_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const config = loadConfig(); + const port = config.port ?? 10100; + if (await proxyHealthy(port)) return port; + await new Promise(resolve => setTimeout(resolve, 150)); + } + return null; +} + +async function chooseListenPort(requestedPort?: number): Promise { + const config = loadConfig(); + const preferred = requestedPort ?? config.port ?? 10100; + const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1"); + if (selected !== preferred) { + console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); + } + if (config.port !== selected) { + config.port = selected; + saveConfig(config); + } + return selected; +} + +async function handleStart(options: { block?: boolean } = {}) { + const existingPid = readPid(); + if (existingPid) { + const config = loadConfig(); + if (await proxyHealthy(config.port)) { + console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`); process.exit(1); } + removePid(); } + const requestedPort = parsePortOption(); + const port = await chooseListenPort(requestedPort); + const server = startServer(port); writePid(process.pid); @@ -97,6 +152,35 @@ async function handleStart(options: { block?: boolean } = {}) { } } +async function handleEnsure() { + let config = loadConfig(); + if (!codexAutoStartEnabled(config)) { + console.log("Codex autostart is disabled."); + return; + } + if (await proxyHealthy(config.port)) { + await syncModelsToCodex(config.port).catch(() => {}); + console.log(`✅ Proxy running on port ${config.port}`); + return; + } + + const child = spawn(process.execPath, [process.argv[1], "start"], { + detached: true, + stdio: "ignore", + env: { ...process.env, OCX_SERVICE: "1" }, + }); + child.unref(); + + const port = await waitForProxy(); + if (!port) { + console.error("❌ Proxy did not become healthy after starting."); + process.exit(1); + } + config = loadConfig(); + await syncModelsToCodex(config.port ?? port).catch(() => {}); + console.log(`✅ Proxy running on port ${config.port ?? port}`); +} + function killProxy(pid: number): void { if (!isProcessAlive(pid)) return; if (process.platform === "win32") { @@ -155,6 +239,58 @@ function handleStop() { if (stopFailed) process.exit(1); } +async function handleUninstall() { + const failures: string[] = []; + + const runStep = (label: string, step: () => void | boolean) => { + try { + const changed = step(); + if (changed === false) console.log(`- ${label}: not installed`); + else console.log(`✅ ${label}`); + } catch (err) { + failures.push(label); + console.error(`⚠️ ${label} failed: ${err instanceof Error ? err.message : String(err)}`); + } + }; + + runStep("service removed", () => { + stopServiceIfInstalled(); + return uninstallServiceIfInstalled(); + }); + + runStep("proxy stopped", () => { + const pid = readPid(); + if (!pid) return false; + killProxy(pid); + removePid(); + return true; + }); + + runStep("native Codex restored", () => { + const r = restoreNativeCodex(); + if (!r.success) throw new Error(r.message); + }); + + try { + const { uninstallCodexShim } = await import("./codex-shim"); + const r = uninstallCodexShim(); + console.log(r.removed ? "✅ Codex autostart shim removed" : "- Codex autostart shim removed: not installed"); + } catch (err) { + failures.push("Codex autostart shim removed"); + console.error(`⚠️ Codex autostart shim removed failed: ${err instanceof Error ? err.message : String(err)}`); + } + + runStep("opencodex config removed", () => { + rmSync(getConfigDir(), { recursive: true, force: true }); + }); + + if (failures.length > 0) { + console.error(`\nUninstall finished with ${failures.length} failed step(s): ${failures.join(", ")}`); + process.exit(1); + } + console.log("\n✅ opencodex local state removed. Remove the package with: npm uninstall -g @bitkyc08/opencodex"); +} + function handleStatus() { const pid = readPid(); if (pid) { @@ -183,9 +319,16 @@ switch (command) { console.log("Plain `codex` now runs natively (no proxy)."); break; } + case "uninstall": + case "remove": + await handleUninstall(); + break; case "status": handleStatus(); break; + case "ensure": + await handleEnsure(); + break; case "login": { const { handleLogin } = await import("./oauth/login-cli"); await handleLogin(args[1]); diff --git a/src/codex-shim.ts b/src/codex-shim.ts index 35ed960d4..5902122e6 100644 --- a/src/codex-shim.ts +++ b/src/codex-shim.ts @@ -4,6 +4,30 @@ import { getConfigDir } from "./config"; const SHIM_MARKER = "opencodex codex autostart shim"; const STATE_PATH = join(getConfigDir(), "codex-shim.json"); +const CODEX_INTERNAL_COMMANDS = [ + "app-server", + "archive", + "apply", + "cloud", + "completion", + "debug", + "delete", + "doctor", + "exec", + "exec-server", + "features", + "fork", + "help", + "login", + "logout", + "mcp", + "plugin", + "resume", + "review", + "sandbox", + "unarchive", + "update", +]; interface ShimState { platform: NodeJS.Platform; @@ -51,42 +75,34 @@ function backupPathFor(path: string): string { } export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string { + const internalCommands = CODEX_INTERNAL_COMMANDS.join("|"); return `#!/usr/bin/env sh # ${SHIM_MARKER} -if [ -z "$OCX_SHIM_BYPASS" ]; then - if ! "${bunPath}" "${cliPath}" status 2>/dev/null | grep -q "Proxy running"; then - mkdir -p "$HOME/.opencodex" - nohup env OCX_SERVICE=1 "${bunPath}" "${cliPath}" start >> "$HOME/.opencodex/shim.log" 2>&1 & - i=0 - while [ "$i" -lt 50 ]; do - if "${bunPath}" "${cliPath}" status 2>/dev/null | grep -q "Proxy running"; then - break - fi - sleep 0.1 - i=$((i + 1)) - done - fi - "${bunPath}" "${cliPath}" sync-cache >/dev/null 2>&1 || true -fi +case "$1" in + ${internalCommands}|--help|-h|--version|-V) + ;; + *) + if [ -z "$OCX_SHIM_BYPASS" ]; then + "${bunPath}" "${cliPath}" ensure >/dev/null 2>&1 || true + fi + ;; +esac exec "${realCodexPath}" "$@" `; } export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string { + const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n"); return `@echo off\r rem ${SHIM_MARKER}\r if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r -"${bunPath}" "${cliPath}" status 2>nul | findstr /C:"Proxy running" >nul\r -if %ERRORLEVEL% EQU 0 goto run_codex\r -if not exist "%USERPROFILE%\\.opencodex" mkdir "%USERPROFILE%\\.opencodex"\r -start "" /b cmd /c "set OCX_SERVICE=1 && ""${bunPath}"" ""${cliPath}"" start >> ""%USERPROFILE%\\.opencodex\\shim.log"" 2>&1"\r -for /l %%i in (1,1,50) do (\r - "${bunPath}" "${cliPath}" status 2>nul | findstr /C:"Proxy running" >nul\r - if not errorlevel 1 goto run_codex\r - powershell -NoProfile -Command "Start-Sleep -Milliseconds 100" >nul 2>nul\r -)\r +${internalCommandChecks}\r +if /I "%~1"=="--help" goto run_codex\r +if /I "%~1"=="-h" goto run_codex\r +if /I "%~1"=="--version" goto run_codex\r +if /I "%~1"=="-V" goto run_codex\r +"${bunPath}" "${cliPath}" ensure >nul 2>nul\r :run_codex\r -"${bunPath}" "${cliPath}" sync-cache >nul 2>nul\r "${realCodexPath}" %*\r `; } diff --git a/src/config.ts b/src/config.ts index 98c88730b..454266568 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,6 +79,10 @@ export function websocketsEnabled(config: Pick): boolea return config.websockets === true; } +export function codexAutoStartEnabled(config: Pick): boolean { + return config.codexAutoStart !== false; +} + export function getDefaultConfig(): OcxConfig { // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key). // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend. @@ -95,6 +99,7 @@ export function getDefaultConfig(): OcxConfig { defaultProvider: "openai", subagentModels: [...DEFAULT_SUBAGENT_MODELS], websockets: false, + codexAutoStart: true, }; } diff --git a/src/init.ts b/src/init.ts index e876bd582..1e16864c6 100644 --- a/src/init.ts +++ b/src/init.ts @@ -131,6 +131,17 @@ export async function runInit(): Promise { console.log(result.success ? `✅ ${result.message}` : `⚠️ ${result.message}`); } + const shimAnswer = await prompt.ask("Install Codex autostart shim? [Y/n]: "); + if (shimAnswer.trim().toLowerCase() !== "n") { + try { + const { installCodexShim } = await import("./codex-shim"); + const result = installCodexShim(); + console.log(result.installed ? `✅ ${result.message}` : `⚠️ ${result.message}`); + } catch (err) { + console.log(`⚠️ Codex autostart shim skipped: ${err instanceof Error ? err.message : String(err)}`); + } + } + console.log(`\n🚀 Setup complete! Run 'ocx start' to start the proxy.`); prompt.close(); } diff --git a/src/ports.ts b/src/ports.ts new file mode 100644 index 000000000..b14f0aaf7 --- /dev/null +++ b/src/ports.ts @@ -0,0 +1,30 @@ +import { createServer } from "node:net"; + +export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise { + return await new Promise(resolve => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen({ port, host: hostname }); + }); +} + +export async function findAvailablePort(preferredPort: number, hostname = "127.0.0.1"): Promise { + if (await isPortAvailable(preferredPort, hostname)) return preferredPort; + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.once("listening", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => { + if (port > 0) resolve(port); + else reject(new Error("failed to allocate an available port")); + }); + }); + server.listen({ port: 0, host: hostname }); + }); +} + diff --git a/src/server.ts b/src/server.ts index 5c43694c3..4fd5c7ac6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,7 +16,7 @@ import { type WsData, } from "./ws-bridge"; import type { ServerWebSocket } from "bun"; -import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config"; +import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, loadConfig, saveConfig, websocketsEnabled } from "./config"; import { parseRequest } from "./responses/parser"; import { routeModel } from "./router"; import { namespacedToolName } from "./types"; @@ -409,6 +409,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P if (url.pathname === "/api/config" && req.method === "GET") { const safeConfig = JSON.parse(JSON.stringify(config)); + safeConfig.codexAutoStart = codexAutoStartEnabled(config); for (const prov of Object.values(safeConfig.providers as Record)) { if (prov.apiKey) prov.apiKey = prov.apiKey.slice(0, 8) + "..."; } @@ -419,6 +420,26 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405); } + if (url.pathname === "/api/settings" && req.method === "GET") { + return jsonResponse({ + codexAutoStart: codexAutoStartEnabled(config), + port: config.port, + hostname: config.hostname ?? "127.0.0.1", + }); + } + + if (url.pathname === "/api/settings" && req.method === "PUT") { + let body: { codexAutoStart?: unknown }; + try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } + if (typeof body.codexAutoStart !== "boolean") { + return jsonResponse({ error: "codexAutoStart boolean is required" }, 400); + } + config.codexAutoStart = body.codexAutoStart; + const { saveConfig: save } = await import("./config"); + save(config); + return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) }); + } + if (url.pathname === "/api/logs" && req.method === "GET") { return jsonResponse(requestLog); } diff --git a/src/service.ts b/src/service.ts index 8a08cc67b..9a27be965 100644 --- a/src/service.ts +++ b/src/service.ts @@ -256,6 +256,27 @@ export function stopServiceIfInstalled(): boolean { return false; } +/** + * Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet + * when no service exists and never exits the process just because the platform has no service + * manager. + */ +export function uninstallServiceIfInstalled(): boolean { + if (process.platform === "darwin") { + if (existsSync(plistPath())) { + try { uninstallLaunchd(); return true; } catch { return false; } + } + } else if (process.platform === "win32") { + try { + const q = sh(`schtasks /query /tn ${TASK} 2>nul`); + if (q.includes(TASK)) { uninstallWindows(); return true; } + } catch { /* task not found */ } + } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) { + try { uninstallSystemd(); return true; } catch { return false; } + } + return false; +} + export function serviceCommand(sub?: string): void { const ops = platformOps(); if (!ops) { diff --git a/src/types.ts b/src/types.ts index 534eb5a6a..d3cf17011 100644 --- a/src/types.ts +++ b/src/types.ts @@ -185,6 +185,8 @@ export interface OcxConfig { connectTimeoutMs?: number; /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */ websockets?: boolean; + /** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */ + codexAutoStart?: boolean; /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */ modelCacheTtlMs?: number; /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */ diff --git a/tests/codex-shim.test.ts b/tests/codex-shim.test.ts index c1c78d233..e986ec3d7 100644 --- a/tests/codex-shim.test.ts +++ b/tests/codex-shim.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { buildUnixCodexShim, buildWindowsCodexShim } from "../src/codex-shim"; @@ -11,10 +12,8 @@ describe("Codex autostart shim", () => { const script = buildUnixCodexShim("/usr/local/bin/codex-real", "/usr/local/bin/bun", "/opt/opencodex/src/cli.ts"); expect(script).toContain(SHIM_MARKER); - expect(script).toContain("status"); - expect(script).toContain("OCX_SERVICE=1"); - expect(script).toContain("start"); - expect(script).toContain("sync-cache"); + expect(script).toContain("ensure"); + expect(script).not.toContain("sync-cache"); expect(script).toContain('exec "/usr/local/bin/codex-real" "$@"'); }); @@ -22,9 +21,8 @@ describe("Codex autostart shim", () => { const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts"); expect(script).toContain(SHIM_MARKER); - expect(script).toContain("findstr"); - expect(script).toContain("OCX_SERVICE=1"); - expect(script).toContain("sync-cache"); + expect(script).toContain("ensure"); + expect(script).not.toContain("sync-cache"); expect(script).toContain('"C:\\Tools\\codex-real.exe" %*'); }); @@ -60,4 +58,40 @@ describe("Codex autostart shim", () => { const script = buildWindowsCodexShim("C:\\codex.exe", "C:\\bun.exe", "C:\\cli.ts"); expect(script).toContain("OCX_SHIM_BYPASS"); }); + + test("Unix shim skips ocx startup for Codex internal commands", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-shim-test-")); + const logPath = join(dir, "calls.log"); + const bunPath = join(dir, "bun"); + const realCodexPath = join(dir, "codex-real"); + const shimPath = join(dir, "codex"); + + writeFileSync(bunPath, `#!/usr/bin/env sh\necho "bun:$*" >> "${logPath}"\n`, "utf8"); + writeFileSync(realCodexPath, `#!/usr/bin/env sh\necho "codex:$*" >> "${logPath}"\n`, "utf8"); + writeFileSync(shimPath, buildUnixCodexShim(realCodexPath, bunPath, "/opt/opencodex/src/cli.ts"), "utf8"); + chmodSync(bunPath, 0o755); + chmodSync(realCodexPath, 0o755); + chmodSync(shimPath, 0o755); + const env = { ...process.env }; + delete env.OCX_SHIM_BYPASS; + + const resume = spawnSync(shimPath, ["resume", "--all"], { encoding: "utf8", env }); + expect(resume.status).toBe(0); + expect(readFileSync(logPath, "utf8")).toBe("codex:resume --all\n"); + + const prompt = spawnSync(shimPath, ["hello"], { encoding: "utf8", env }); + expect(prompt.status).toBe(0); + expect(readFileSync(logPath, "utf8")).toBe( + "codex:resume --all\nbun:/opt/opencodex/src/cli.ts ensure\ncodex:hello\n", + ); + }); + + test("Windows shim skips ocx startup for Codex internal commands", () => { + const script = buildWindowsCodexShim("C:\\Tools\\codex-real.exe", "C:\\Bun\\bun.exe", "C:\\ocx\\cli.ts"); + + expect(script).toContain('if /I "%~1"=="resume" goto run_codex'); + expect(script).toContain('if /I "%~1"=="app-server" goto run_codex'); + expect(script).toContain('if /I "%~1"=="exec" goto run_codex'); + expect(script).toContain('if /I "%~1"=="--help" goto run_codex'); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 000000000..1fd8cea9a --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { codexAutoStartEnabled, getDefaultConfig } from "../src/config"; + +describe("opencodex config defaults", () => { + test("Codex autostart is enabled by default", () => { + expect(getDefaultConfig().codexAutoStart).toBe(true); + expect(codexAutoStartEnabled({})).toBe(true); + }); + + test("Codex autostart can be disabled explicitly", () => { + expect(codexAutoStartEnabled({ codexAutoStart: false })).toBe(false); + expect(codexAutoStartEnabled({ codexAutoStart: true })).toBe(true); + }); +}); diff --git a/tests/ports.test.ts b/tests/ports.test.ts new file mode 100644 index 000000000..23ac55fbb --- /dev/null +++ b/tests/ports.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createServer, type Server } from "node:net"; +import { findAvailablePort, isPortAvailable } from "../src/ports"; + +const servers: Server[] = []; + +function close(server: Server): Promise { + return new Promise(resolve => server.close(() => resolve())); +} + +function listen(port = 0): Promise<{ server: Server; port: number }> { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.once("listening", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("unexpected server address")); + return; + } + servers.push(server); + resolve({ server, port: address.port }); + }); + server.listen({ port, host: "127.0.0.1" }); + }); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(close)); +}); + +describe("port selection", () => { + test("keeps the preferred port when it is free", async () => { + const { port } = await listen(); + const server = servers.pop(); + if (server) await close(server); + + expect(await isPortAvailable(port)).toBe(true); + expect(await findAvailablePort(port)).toBe(port); + }); + + test("falls back to another available port when the preferred port is busy", async () => { + const { port } = await listen(); + + expect(await isPortAvailable(port)).toBe(false); + const selected = await findAvailablePort(port); + expect(selected).not.toBe(port); + expect(await isPortAvailable(selected)).toBe(true); + }); +}); diff --git a/tests/startup-prompt.test.ts b/tests/startup-prompt.test.ts index 72421e7fc..f42603c7c 100644 --- a/tests/startup-prompt.test.ts +++ b/tests/startup-prompt.test.ts @@ -27,4 +27,11 @@ describe("startup star prompt", () => { expect(syncIndex).toBeGreaterThan(-1); expect(promptIndex).toBeLessThan(syncIndex); }); + + test("ocx init offers the Codex autostart shim by default", async () => { + const init = await readText("src/init.ts"); + + expect(init).toContain("Install Codex autostart shim? [Y/n]"); + expect(init).toContain("installCodexShim"); + }); }); diff --git a/tests/uninstall.test.ts b/tests/uninstall.test.ts new file mode 100644 index 000000000..6235139f1 --- /dev/null +++ b/tests/uninstall.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; + +const root = new URL("../", import.meta.url); + +async function readText(path: string): Promise { + return await Bun.file(new URL(path, root)).text(); +} + +describe("full uninstall command", () => { + test("CLI exposes a one-shot local state cleanup command", async () => { + const cli = await readText("src/cli.ts"); + + expect(cli).toContain("ocx uninstall"); + expect(cli).toContain("async function handleUninstall()"); + expect(cli).toContain("uninstallServiceIfInstalled"); + expect(cli).toContain("uninstallCodexShim"); + expect(cli).toContain("restoreNativeCodex"); + expect(cli).toContain("rmSync(getConfigDir()"); + }); + + test("service cleanup has a quiet best-effort helper", async () => { + const service = await readText("src/service.ts"); + + expect(service).toContain("export function uninstallServiceIfInstalled()"); + expect(service).toContain("uninstallLaunchd"); + expect(service).toContain("uninstallWindows"); + expect(service).toContain("uninstallSystemd"); + }); +});