diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index 2ed77e4e8..a4c832dd2 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -47,6 +47,16 @@ pub(crate) async fn codex_doctor( .await } +#[tauri::command] +pub(crate) async fn codex_update( + codex_bin: Option, + codex_args: Option, + state: State<'_, AppState>, +) -> Result { + crate::shared::codex_update_core::codex_update_core(&state.app_settings, codex_bin, codex_args) + .await +} + #[tauri::command] pub(crate) async fn start_thread( workspace_id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a2734a1ab..fa078e0aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -111,6 +111,7 @@ pub fn run() { codex::get_config_model, menu::menu_set_accelerators, codex::codex_doctor, + codex::codex_update, workspaces::list_workspaces, workspaces::is_workspace_path_dir, workspaces::add_workspace, diff --git a/src-tauri/src/shared/codex_update_core.rs b/src-tauri/src/shared/codex_update_core.rs new file mode 100644 index 000000000..3eed37bd3 --- /dev/null +++ b/src-tauri/src/shared/codex_update_core.rs @@ -0,0 +1,249 @@ +use serde_json::Value; +use std::time::Duration; + +use tokio::sync::Mutex; +use tokio::time::timeout; + +use crate::backend::app_server::check_codex_installation; +use crate::shared::process_core::tokio_command; +use crate::types::AppSettings; + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct CodexUpdateResult { + ok: bool, + method: String, + package: Option, + before_version: Option, + after_version: Option, + upgraded: bool, + output: Option, + details: Option, +} + +fn trim_lines(value: &str, max_len: usize) -> String { + let trimmed = value.trim(); + if trimmed.len() <= max_len { + return trimmed.to_string(); + } + + let mut shortened = trimmed[..max_len].to_string(); + shortened.push_str("…"); + shortened +} + +async fn run_brew_info(args: &[&str]) -> Result { + let mut command = tokio_command("brew"); + command.arg("info"); + command.args(args); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let output = match timeout(Duration::from_secs(8), command.output()).await { + Ok(result) => match result { + Ok(output) => output, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + return Ok(false); + } + return Err(err.to_string()); + } + }, + Err(_) => return Ok(false), + }; + + Ok(output.status.success()) +} + +async fn detect_brew_cask(name: &str) -> Result { + run_brew_info(&["--cask", name]).await +} + +async fn detect_brew_formula(name: &str) -> Result { + run_brew_info(&["--formula", name]).await +} + +async fn run_brew_upgrade(args: &[&str]) -> Result<(bool, String), String> { + let mut command = tokio_command("brew"); + command.arg("upgrade"); + command.args(args); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let output = match timeout(Duration::from_secs(60 * 10), command.output()).await { + Ok(result) => result.map_err(|err| err.to_string())?, + Err(_) => return Err("Timed out while running `brew upgrade`.".to_string()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}\n{}", stdout.trim_end(), stderr.trim_end()); + Ok((output.status.success(), combined.trim().to_string())) +} + +fn brew_output_indicates_upgrade(output: &str) -> bool { + let lower = output.to_ascii_lowercase(); + if lower.contains("already up-to-date") { + return false; + } + if lower.contains("already installed") && lower.contains("latest") { + return false; + } + if lower.contains("upgraded") { + return true; + } + if lower.contains("installing") || lower.contains("pouring") { + return true; + } + false +} + +async fn npm_has_package(package: &str) -> Result { + let mut command = tokio_command("npm"); + command.arg("list"); + command.arg("-g"); + command.arg(package); + command.arg("--depth=0"); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let output = match timeout(Duration::from_secs(10), command.output()).await { + Ok(result) => match result { + Ok(output) => output, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + return Ok(false); + } + return Err(err.to_string()); + } + }, + Err(_) => return Ok(false), + }; + + Ok(output.status.success()) +} + +async fn run_npm_install_latest(package: &str) -> Result<(bool, String), String> { + let mut command = tokio_command("npm"); + command.arg("install"); + command.arg("-g"); + command.arg(format!("{package}@latest")); + command.stdout(std::process::Stdio::piped()); + command.stderr(std::process::Stdio::piped()); + + let output = match timeout(Duration::from_secs(60 * 10), command.output()).await { + Ok(result) => result.map_err(|err| err.to_string())?, + Err(_) => return Err("Timed out while running `npm install -g`.".to_string()), + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}\n{}", stdout.trim_end(), stderr.trim_end()); + Ok((output.status.success(), combined.trim().to_string())) +} + +pub(crate) async fn codex_update_core( + app_settings: &Mutex, + codex_bin: Option, + codex_args: Option, +) -> Result { + let (default_bin, default_args) = { + let settings = app_settings.lock().await; + (settings.codex_bin.clone(), settings.codex_args.clone()) + }; + let resolved = codex_bin + .clone() + .filter(|value| !value.trim().is_empty()) + .or(default_bin); + let resolved_args = codex_args + .clone() + .filter(|value| !value.trim().is_empty()) + .or(default_args); + let _ = resolved_args; + + let before_version = check_codex_installation(resolved.clone()) + .await + .ok() + .flatten(); + + let (method, package, upgrade_ok, output, upgraded) = if detect_brew_cask("codex").await? { + let (ok, output) = run_brew_upgrade(&["--cask", "codex"]).await?; + let upgraded = brew_output_indicates_upgrade(&output); + ( + "brew_cask".to_string(), + Some("codex".to_string()), + ok, + output, + upgraded, + ) + } else if detect_brew_formula("codex").await? { + let (ok, output) = run_brew_upgrade(&["codex"]).await?; + let upgraded = brew_output_indicates_upgrade(&output); + ( + "brew_formula".to_string(), + Some("codex".to_string()), + ok, + output, + upgraded, + ) + } else if npm_has_package("@openai/codex").await? { + let (ok, output) = run_npm_install_latest("@openai/codex").await?; + ( + "npm".to_string(), + Some("@openai/codex".to_string()), + ok, + output, + ok, + ) + } else { + ( + "unknown".to_string(), + None, + false, + String::new(), + false, + ) + }; + + let after_version = if method == "unknown" { + None + } else { + match check_codex_installation(resolved.clone()).await { + Ok(version) => version, + Err(err) => { + let result = CodexUpdateResult { + ok: false, + method, + package, + before_version, + after_version: None, + upgraded, + output: Some(trim_lines(&output, 8000)), + details: Some(err), + }; + return serde_json::to_value(result).map_err(|e| e.to_string()); + } + } + }; + + let details = if method == "unknown" { + Some("Unable to detect Codex installation method (brew/npm).".to_string()) + } else if upgrade_ok { + None + } else { + Some("Codex update failed.".to_string()) + }; + + let result = CodexUpdateResult { + ok: upgrade_ok, + method, + package, + before_version, + after_version, + upgraded, + output: Some(trim_lines(&output, 8000)), + details, + }; + + serde_json::to_value(result).map_err(|err| err.to_string()) +} diff --git a/src-tauri/src/shared/mod.rs b/src-tauri/src/shared/mod.rs index c6e361a0e..485fb76fd 100644 --- a/src-tauri/src/shared/mod.rs +++ b/src-tauri/src/shared/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod account; pub(crate) mod codex_aux_core; +pub(crate) mod codex_update_core; pub(crate) mod codex_core; pub(crate) mod files_core; pub(crate) mod git_core; diff --git a/src/App.tsx b/src/App.tsx index b320e0a30..984f109f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -161,6 +161,7 @@ function MainApp() { appSettings, setAppSettings, doctor, + codexUpdate, appSettingsLoading, reduceTransparency, setReduceTransparency, @@ -2462,6 +2463,7 @@ function MainApp() { await queueSaveSettings(next); }, onRunDoctor: doctor, + onRunCodexUpdate: codexUpdate, onUpdateWorkspaceCodexBin: async (id, codexBin) => { await updateWorkspaceCodexBin(id, codexBin); }, diff --git a/src/features/app/hooks/useAppSettingsController.ts b/src/features/app/hooks/useAppSettingsController.ts index a40f9ff74..e9e37cf7a 100644 --- a/src/features/app/hooks/useAppSettingsController.ts +++ b/src/features/app/hooks/useAppSettingsController.ts @@ -2,6 +2,7 @@ import { useThemePreference } from "../../layout/hooks/useThemePreference"; import { useTransparencyPreference } from "../../layout/hooks/useTransparencyPreference"; import { useUiScaleShortcuts } from "../../layout/hooks/useUiScaleShortcuts"; import { useAppSettings } from "../../settings/hooks/useAppSettings"; +import { runCodexUpdate } from "../../../services/tauri"; export function useAppSettingsController() { const { @@ -33,6 +34,8 @@ export function useAppSettingsController() { saveSettings, queueSaveSettings, doctor, + codexUpdate: (codexBin: string | null, codexArgs: string | null) => + runCodexUpdate(codexBin, codexArgs), appSettingsLoading, reduceTransparency, setReduceTransparency, diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 403fcf9c9..653733fe6 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -112,6 +112,17 @@ const createDoctorResult = () => ({ nodeDetails: null, }); +const createUpdateResult = () => ({ + ok: true, + method: "brew_formula" as const, + package: "codex", + beforeVersion: "codex 0.0.0", + afterVersion: "codex 0.0.1", + upgraded: true, + output: null, + details: null, +}); + const renderDisplaySection = ( options: { appSettings?: Partial; @@ -592,6 +603,7 @@ describe("SettingsView Codex overrides", () => { openAppIconById={{}} onUpdateAppSettings={vi.fn().mockResolvedValue(undefined)} onRunDoctor={vi.fn().mockResolvedValue(createDoctorResult())} + onRunCodexUpdate={vi.fn().mockResolvedValue(createUpdateResult())} onUpdateWorkspaceCodexBin={vi.fn().mockResolvedValue(undefined)} onUpdateWorkspaceSettings={onUpdateWorkspaceSettings} scaleShortcutTitle="Scale shortcut" @@ -639,6 +651,7 @@ describe("SettingsView Codex overrides", () => { openAppIconById={{}} onUpdateAppSettings={onUpdateAppSettings} onRunDoctor={vi.fn().mockResolvedValue(createDoctorResult())} + onRunCodexUpdate={vi.fn().mockResolvedValue(createUpdateResult())} onUpdateWorkspaceCodexBin={vi.fn().mockResolvedValue(undefined)} onUpdateWorkspaceSettings={vi.fn().mockResolvedValue(undefined)} scaleShortcutTitle="Scale shortcut" diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 522b94220..73829796d 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -6,6 +6,7 @@ import X from "lucide-react/dist/esm/icons/x"; import type { AppSettings, CodexDoctorResult, + CodexUpdateResult, DictationModelStatus, OrbitConnectTestResult, OrbitRunnerStatus, @@ -282,6 +283,10 @@ export type SettingsViewProps = { codexBin: string | null, codexArgs: string | null, ) => Promise; + onRunCodexUpdate?: ( + codexBin: string | null, + codexArgs: string | null, + ) => Promise; onUpdateWorkspaceCodexBin: (id: string, codexBin: string | null) => Promise; onUpdateWorkspaceSettings: ( id: string, @@ -380,6 +385,7 @@ export function SettingsView({ openAppIconById, onUpdateAppSettings, onRunDoctor, + onRunCodexUpdate, onUpdateWorkspaceCodexBin, onUpdateWorkspaceSettings, scaleShortcutTitle, @@ -480,6 +486,11 @@ export function SettingsView({ status: "idle" | "running" | "done"; result: CodexDoctorResult | null; }>({ status: "idle", result: null }); + + const [codexUpdateState, setCodexUpdateState] = useState<{ + status: "idle" | "running" | "done"; + result: CodexUpdateResult | null; + }>({ status: "idle", result: null }); const { content: globalAgentsContent, exists: globalAgentsExists, @@ -1581,6 +1592,45 @@ export function SettingsView({ } }; + const handleRunCodexUpdate = async () => { + setCodexUpdateState({ status: "running", result: null }); + try { + if (!onRunCodexUpdate) { + setCodexUpdateState({ + status: "done", + result: { + ok: false, + method: "unknown", + package: null, + beforeVersion: null, + afterVersion: null, + upgraded: false, + output: null, + details: "Codex updates are not available in this build.", + }, + }); + return; + } + + const result = await onRunCodexUpdate(nextCodexBin, nextCodexArgs); + setCodexUpdateState({ status: "done", result }); + } catch (error) { + setCodexUpdateState({ + status: "done", + result: { + ok: false, + method: "unknown", + package: null, + beforeVersion: null, + afterVersion: null, + upgraded: false, + output: null, + details: error instanceof Error ? error.message : String(error), + }, + }); + } + }; + const updateShortcut = async (key: ShortcutSettingKey, value: string | null) => { const draftKey = shortcutDraftKeyBySetting[key]; setShortcutDrafts((prev) => ({ @@ -1981,6 +2031,7 @@ export function SettingsView({ codexDirty={codexDirty} isSavingSettings={isSavingSettings} doctorState={doctorState} + codexUpdateState={codexUpdateState} globalAgentsMeta={globalAgentsMeta} globalAgentsError={globalAgentsError} globalAgentsContent={globalAgentsContent} @@ -2009,6 +2060,7 @@ export function SettingsView({ onBrowseCodex={handleBrowseCodex} onSaveCodexSettings={handleSaveCodexSettings} onRunDoctor={handleRunDoctor} + onRunCodexUpdate={handleRunCodexUpdate} onRefreshGlobalAgents={() => { void refreshGlobalAgents(); }} diff --git a/src/features/settings/components/sections/SettingsCodexSection.tsx b/src/features/settings/components/sections/SettingsCodexSection.tsx index 6786990d8..0d2c5f06b 100644 --- a/src/features/settings/components/sections/SettingsCodexSection.tsx +++ b/src/features/settings/components/sections/SettingsCodexSection.tsx @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react"; import type { AppSettings, CodexDoctorResult, + CodexUpdateResult, WorkspaceInfo, } from "../../../../types"; import { FileEditorCard } from "../../../shared/components/FileEditorCard"; @@ -18,6 +19,10 @@ type SettingsCodexSectionProps = { status: "idle" | "running" | "done"; result: CodexDoctorResult | null; }; + codexUpdateState: { + status: "idle" | "running" | "done"; + result: CodexUpdateResult | null; + }; globalAgentsMeta: string; globalAgentsError: string | null; globalAgentsContent: string; @@ -46,6 +51,7 @@ type SettingsCodexSectionProps = { onBrowseCodex: () => Promise; onSaveCodexSettings: () => Promise; onRunDoctor: () => Promise; + onRunCodexUpdate: () => Promise; onRefreshGlobalAgents: () => void; onSaveGlobalAgents: () => void; onRefreshGlobalConfig: () => void; @@ -70,6 +76,7 @@ export function SettingsCodexSection({ codexDirty, isSavingSettings, doctorState, + codexUpdateState, globalAgentsMeta, globalAgentsError, globalAgentsContent, @@ -98,6 +105,7 @@ export function SettingsCodexSection({ onBrowseCodex, onSaveCodexSettings, onRunDoctor, + onRunCodexUpdate, onRefreshGlobalAgents, onSaveGlobalAgents, onRefreshGlobalConfig, @@ -187,6 +195,18 @@ export function SettingsCodexSection({ {doctorState.status === "running" ? "Running..." : "Run doctor"} + {doctorState.result && ( @@ -211,6 +231,39 @@ export function SettingsCodexSection({ )} + + {codexUpdateState.result && ( +
+
+ {codexUpdateState.result.ok + ? codexUpdateState.result.upgraded + ? "Codex updated" + : "Codex already up-to-date" + : "Codex update failed"} +
+
+
Method: {codexUpdateState.result.method}
+ {codexUpdateState.result.package && ( +
Package: {codexUpdateState.result.package}
+ )} +
+ Version:{" "} + {codexUpdateState.result.afterVersion ?? + codexUpdateState.result.beforeVersion ?? + "unknown"} +
+ {codexUpdateState.result.details &&
{codexUpdateState.result.details}
} + {codexUpdateState.result.output && ( +
+ output +
{codexUpdateState.result.output}
+
+ )} +
+
+ )}
diff --git a/src/services/tauri.ts b/src/services/tauri.ts index faf546aba..9de41fbe4 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import type { Options as NotificationOptions } from "@tauri-apps/plugin-notification"; import type { AppSettings, + CodexUpdateResult, CodexDoctorResult, DictationModelStatus, DictationSessionState, @@ -662,6 +663,13 @@ export async function runCodexDoctor( return invoke("codex_doctor", { codexBin, codexArgs }); } +export async function runCodexUpdate( + codexBin: string | null, + codexArgs: string | null, +): Promise { + return invoke("codex_update", { codexBin, codexArgs }); +} + export async function getWorkspaceFiles(workspaceId: string) { return invoke("list_workspace_files", { workspaceId }); } diff --git a/src/types.ts b/src/types.ts index 97437b6b4..0bfcab2da 100644 --- a/src/types.ts +++ b/src/types.ts @@ -300,6 +300,19 @@ export type CodexDoctorResult = { nodeDetails: string | null; }; +export type CodexUpdateMethod = "brew_formula" | "brew_cask" | "npm" | "unknown"; + +export type CodexUpdateResult = { + ok: boolean; + method: CodexUpdateMethod; + package: string | null; + beforeVersion: string | null; + afterVersion: string | null; + upgraded: boolean; + output: string | null; + details: string | null; +}; + export type ApprovalRequest = { workspace_id: string; request_id: number | string;