mirror of
https://github.com/openai/codex.git
synced 2026-05-17 12:02:55 +03:00
Compare commits
3 Commits
remove_img
...
fcoury/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c6e06381f | ||
|
|
f12a36c166 | ||
|
|
d0a905a557 |
@@ -2,7 +2,7 @@
|
||||
// Unified entry point for the Codex CLI.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
@@ -171,6 +171,12 @@ const packageManagerEnvVar =
|
||||
? "CODEX_MANAGED_BY_BUN"
|
||||
: "CODEX_MANAGED_BY_NPM";
|
||||
env[packageManagerEnvVar] = "1";
|
||||
try {
|
||||
env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, ".."));
|
||||
} catch {
|
||||
// Best effort only. Older or unusual package layouts can omit this extra
|
||||
// provenance without preventing Codex from starting.
|
||||
}
|
||||
|
||||
const child = spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -34,7 +34,10 @@ use codex_state::state_db_path;
|
||||
use codex_tui::AppExitInfo;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
use codex_tui::ExitReason;
|
||||
use codex_tui::PromptedUpdate;
|
||||
use codex_tui::UpdateAction;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use codex_tui::UpdateActionStatus;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use owo_colors::OwoColorize;
|
||||
@@ -618,13 +621,13 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
|
||||
ExitReason::UserRequested => { /* normal exit */ }
|
||||
}
|
||||
|
||||
let update_action = exit_info.update_action;
|
||||
let prompted_update = exit_info.update_action.clone();
|
||||
let color_enabled = supports_color::on(Stream::Stdout).is_some();
|
||||
for line in format_exit_messages(exit_info, color_enabled) {
|
||||
println!("{line}");
|
||||
}
|
||||
if let Some(action) = update_action {
|
||||
run_update_action(action)?;
|
||||
if let Some(prompted_update) = prompted_update {
|
||||
run_prompted_update(prompted_update)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -672,6 +675,20 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_prompted_update(prompted_update: PromptedUpdate) -> anyhow::Result<()> {
|
||||
run_update_action(prompted_update.action)?;
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
if let Err(err) = codex_tui::record_successful_prompt_update_attempt(
|
||||
&prompted_update.version_file,
|
||||
&prompted_update.target_version,
|
||||
) {
|
||||
tracing::warn!("Failed to record successful prompted update attempt: {err}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_update_command() -> anyhow::Result<()> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
@@ -682,12 +699,13 @@ fn run_update_command() -> anyhow::Result<()> {
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let Some(action) = codex_tui::get_update_action() else {
|
||||
anyhow::bail!(
|
||||
match codex_tui::get_update_action_status() {
|
||||
UpdateActionStatus::Ready(action) => run_update_action(action),
|
||||
UpdateActionStatus::Blocked(blocker) => anyhow::bail!("{blocker}"),
|
||||
UpdateActionStatus::Unavailable => anyhow::bail!(
|
||||
"Could not detect the Codex installation method. Please update manually: https://developers.openai.com/codex/cli/"
|
||||
);
|
||||
};
|
||||
run_update_action(action)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateAvailableHistoryCell;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::history_cell::UpdateRemediationWarningHistoryCell;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::legacy_core::config::Config;
|
||||
@@ -73,7 +75,9 @@ use crate::token_usage::TokenUsage;
|
||||
use crate::transcript_reflow::TranscriptReflowState;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::update_action::PromptedUpdate;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::updates::UpgradeHistoryNotice;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use crate::workspace_command::AppServerWorkspaceCommandRunner;
|
||||
use crate::workspace_command::WorkspaceCommandRunner;
|
||||
@@ -335,7 +339,7 @@ pub struct AppExitInfo {
|
||||
pub token_usage: TokenUsage,
|
||||
pub thread_id: Option<ThreadId>,
|
||||
pub thread_name: Option<String>,
|
||||
pub update_action: Option<UpdateAction>,
|
||||
pub update_action: Option<PromptedUpdate>,
|
||||
pub exit_reason: ExitReason,
|
||||
}
|
||||
|
||||
@@ -478,7 +482,7 @@ pub(crate) struct App {
|
||||
remote_app_server_url: Option<String>,
|
||||
remote_app_server_auth_token: Option<String>,
|
||||
/// Set when the user confirms an update; propagated on exit.
|
||||
pub(crate) pending_update_action: Option<UpdateAction>,
|
||||
pub(crate) pending_update_action: Option<PromptedUpdate>,
|
||||
|
||||
/// Tracks the thread we intentionally shut down while exiting the app.
|
||||
///
|
||||
@@ -863,7 +867,13 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
)
|
||||
})?;
|
||||
#[cfg(not(debug_assertions))]
|
||||
let upgrade_version = crate::updates::get_upgrade_version(&config);
|
||||
let upgrade_notice = crate::updates::get_upgrade_notice_for_history(
|
||||
&config,
|
||||
initial_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| !prompt.is_empty()),
|
||||
crate::update_action::get_update_action_status(),
|
||||
);
|
||||
|
||||
let mut app = Self {
|
||||
model_catalog,
|
||||
@@ -968,16 +978,24 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
let mut waiting_for_initial_session_configured = wait_for_initial_session_configured;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let pre_loop_exit_reason = if let Some(latest_version) = upgrade_version {
|
||||
let pre_loop_exit_reason = if let Some(upgrade_notice) = upgrade_notice {
|
||||
let cell: Box<dyn HistoryCell> = match upgrade_notice {
|
||||
UpgradeHistoryNotice::Available {
|
||||
latest_version,
|
||||
update_action,
|
||||
} => Box::new(UpdateAvailableHistoryCell::new(
|
||||
latest_version,
|
||||
update_action,
|
||||
)),
|
||||
UpgradeHistoryNotice::BlockedWarning(blocker) => {
|
||||
Box::new(UpdateRemediationWarningHistoryCell::Blocked(blocker))
|
||||
}
|
||||
UpgradeHistoryNotice::NoOpUpdateWarning { latest_version } => {
|
||||
Box::new(UpdateRemediationWarningHistoryCell::NoOpUpdate { latest_version })
|
||||
}
|
||||
};
|
||||
let control = app
|
||||
.handle_event(
|
||||
tui,
|
||||
&mut app_server,
|
||||
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
|
||||
latest_version,
|
||||
crate::update_action::get_update_action(),
|
||||
))),
|
||||
)
|
||||
.handle_event(tui, &mut app_server, AppEvent::InsertHistoryCell(cell))
|
||||
.await?;
|
||||
match control {
|
||||
AppRunControl::Continue => None,
|
||||
@@ -1078,7 +1096,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
token_usage: app.token_usage(),
|
||||
thread_id: resumable_thread.as_ref().map(|thread| thread.thread_id),
|
||||
thread_name: resumable_thread.and_then(|thread| thread.thread_name),
|
||||
update_action: app.pending_update_action,
|
||||
update_action: app.pending_update_action.clone(),
|
||||
exit_reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ use crate::text_formatting::truncate_text;
|
||||
use crate::tooltips;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::update_action::UpdateBlocker;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_line;
|
||||
@@ -656,6 +657,40 @@ pub(crate) struct UpdateAvailableHistoryCell {
|
||||
update_action: Option<UpdateAction>,
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, allow(dead_code))]
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum UpdateRemediationWarningHistoryCell {
|
||||
Blocked(UpdateBlocker),
|
||||
NoOpUpdate { latest_version: String },
|
||||
}
|
||||
|
||||
impl HistoryCell for UpdateRemediationWarningHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.raw_lines()
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
match self {
|
||||
Self::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
|
||||
running_package_root,
|
||||
npm_package_root,
|
||||
}) => vec![Line::from(format!(
|
||||
"Warning: Codex is running from {}, but npm would update {}.",
|
||||
running_package_root.display(),
|
||||
npm_package_root.display(),
|
||||
))],
|
||||
Self::NoOpUpdate { latest_version } => vec![
|
||||
Line::from(
|
||||
"Warning: The previous update completed, but this Codex executable did not change.",
|
||||
),
|
||||
Line::from(format!(
|
||||
"Codex is still running {CODEX_CLI_VERSION} while {latest_version} is available."
|
||||
)),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, allow(dead_code))]
|
||||
impl UpdateAvailableHistoryCell {
|
||||
pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {
|
||||
|
||||
@@ -177,13 +177,20 @@ mod transcript_reflow;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
pub(crate) mod update_action;
|
||||
pub use update_action::PromptedUpdate;
|
||||
pub use update_action::UpdateAction;
|
||||
pub use update_action::UpdateActionStatus;
|
||||
pub use update_action::UpdateBlocker;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use update_action::get_update_action;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub use update_action::get_update_action_status;
|
||||
mod update_prompt;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
mod update_versions;
|
||||
mod updates;
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub use updates::record_successful_prompt_update_attempt;
|
||||
mod version;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod voice;
|
||||
@@ -1094,8 +1101,9 @@ pub async fn run_main(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(unused_mut)]
|
||||
async fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
mut cli: Cli,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
loader_overrides: LoaderOverrides,
|
||||
app_server_target: AppServerTarget,
|
||||
@@ -1149,6 +1157,9 @@ async fn run_ratatui_app(
|
||||
exit_reason: ExitReason::UserRequested,
|
||||
});
|
||||
}
|
||||
UpdatePromptOutcome::StartRepairSession(prompt) => {
|
||||
cli.prompt = Some(prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
pub(crate) const PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
source: tui/src/update_prompt.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
|
||||
✨ Update available! 0.0.0 -> 9.9.9
|
||||
|
||||
Release notes: https://github.com/openai/codex/releases/latest
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/update_prompt.rs
|
||||
assertion_line: 592
|
||||
expression: terminal.backend()
|
||||
---
|
||||
|
||||
✨ Update needs attention
|
||||
|
||||
You are running Codex from:
|
||||
/prefix-a/lib/node_modules/@openai/codex
|
||||
but `npm install -g @openai/codex@latest` would update:
|
||||
/prefix-b/lib/node_modules/@openai/codex
|
||||
Fix your shell PATH or remove the stale Codex install, then restart Codex.
|
||||
|
||||
› 1. Help me fix this
|
||||
2. Later
|
||||
3. Don't remind me about this version
|
||||
@@ -2,6 +2,15 @@
|
||||
use codex_install_context::InstallContext;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
use codex_install_context::StandalonePlatform;
|
||||
use std::fmt;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
const MANAGED_PACKAGE_ROOT_ENV: &str = "CODEX_MANAGED_PACKAGE_ROOT";
|
||||
|
||||
/// Update action the CLI should perform after the TUI exits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -18,6 +27,62 @@ pub enum UpdateAction {
|
||||
StandaloneWindows,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UpdateActionStatus {
|
||||
Ready(UpdateAction),
|
||||
Blocked(UpdateBlocker),
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UpdateBlocker {
|
||||
NpmGlobalRootMismatch {
|
||||
running_package_root: PathBuf,
|
||||
npm_package_root: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptedUpdate {
|
||||
pub action: UpdateAction,
|
||||
pub target_version: String,
|
||||
pub version_file: PathBuf,
|
||||
}
|
||||
|
||||
impl UpdateBlocker {
|
||||
pub fn remediation_lines(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::NpmGlobalRootMismatch {
|
||||
running_package_root,
|
||||
npm_package_root,
|
||||
} => vec![
|
||||
"You are running Codex from:".to_string(),
|
||||
format!(" {}", running_package_root.display()),
|
||||
"but `npm install -g @openai/codex@latest` would update:".to_string(),
|
||||
format!(" {}", npm_package_root.display()),
|
||||
"Fix your shell PATH or remove the stale Codex install, then restart Codex."
|
||||
.to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UpdateBlocker {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::NpmGlobalRootMismatch {
|
||||
running_package_root,
|
||||
npm_package_root,
|
||||
} => write!(
|
||||
f,
|
||||
"You are running Codex from {}, but `npm install -g @openai/codex@latest` would update {}. Fix your shell PATH or remove the stale Codex install, then restart Codex.",
|
||||
running_package_root.display(),
|
||||
npm_package_root.display(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateAction {
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub(crate) fn from_install_context(context: &InstallContext) -> Option<Self> {
|
||||
@@ -36,8 +101,8 @@ impl UpdateAction {
|
||||
/// Returns the list of command-line arguments for invoking the update.
|
||||
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]),
|
||||
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]),
|
||||
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
|
||||
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
|
||||
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]),
|
||||
UpdateAction::StandaloneUnix => (
|
||||
"sh",
|
||||
@@ -58,11 +123,79 @@ impl UpdateAction {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
pub fn get_update_action() -> Option<UpdateAction> {
|
||||
UpdateAction::from_install_context(InstallContext::current())
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub fn get_update_action_status() -> UpdateActionStatus {
|
||||
let Some(action) = UpdateAction::from_install_context(InstallContext::current()) else {
|
||||
return UpdateActionStatus::Unavailable;
|
||||
};
|
||||
|
||||
if let Some(blocker) = update_blocker(action) {
|
||||
UpdateActionStatus::Blocked(blocker)
|
||||
} else {
|
||||
UpdateActionStatus::Ready(action)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn update_blocker(action: UpdateAction) -> Option<UpdateBlocker> {
|
||||
match action {
|
||||
UpdateAction::NpmGlobalLatest => npm_global_root_mismatch(),
|
||||
UpdateAction::BunGlobalLatest
|
||||
| UpdateAction::BrewUpgrade
|
||||
| UpdateAction::StandaloneUnix
|
||||
| UpdateAction::StandaloneWindows => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn npm_global_root_mismatch() -> Option<UpdateBlocker> {
|
||||
let running_package_root = std::env::var_os(MANAGED_PACKAGE_ROOT_ENV)?;
|
||||
let running_package_root = std::fs::canonicalize(PathBuf::from(running_package_root)).ok()?;
|
||||
let npm_global_root = npm_global_root()?;
|
||||
mismatch_from_npm_roots(&running_package_root, &npm_global_root)
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn npm_global_root() -> Option<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "npm", "root", "-g"])
|
||||
.output()
|
||||
.ok()?;
|
||||
#[cfg(not(windows))]
|
||||
let output = Command::new("npm").args(["root", "-g"]).output().ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let stdout = String::from_utf8(output.stdout).ok()?;
|
||||
let npm_global_root = stdout.trim();
|
||||
if npm_global_root.is_empty() {
|
||||
return None;
|
||||
}
|
||||
std::fs::canonicalize(npm_global_root).ok()
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn mismatch_from_npm_roots(
|
||||
running_package_root: &Path,
|
||||
npm_global_root: &Path,
|
||||
) -> Option<UpdateBlocker> {
|
||||
let npm_package_root = npm_global_root.join("@openai").join("codex");
|
||||
(running_package_root != npm_package_root.as_path()).then(|| {
|
||||
UpdateBlocker::NpmGlobalRootMismatch {
|
||||
running_package_root: running_package_root.to_path_buf(),
|
||||
npm_package_root,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -124,4 +257,29 @@ mod tests {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_root_mismatch_is_blocked_when_update_targets_another_install() {
|
||||
assert_eq!(
|
||||
mismatch_from_npm_roots(
|
||||
Path::new("/prefix-a/lib/node_modules/@openai/codex"),
|
||||
Path::new("/prefix-b/lib/node_modules"),
|
||||
),
|
||||
Some(UpdateBlocker::NpmGlobalRootMismatch {
|
||||
running_package_root: PathBuf::from("/prefix-a/lib/node_modules/@openai/codex"),
|
||||
npm_package_root: PathBuf::from("/prefix-b/lib/node_modules/@openai/codex"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_root_match_keeps_update_available() {
|
||||
assert_eq!(
|
||||
mismatch_from_npm_roots(
|
||||
Path::new("/prefix-a/lib/node_modules/@openai/codex"),
|
||||
Path::new("/prefix-a/lib/node_modules"),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![cfg(not(debug_assertions))]
|
||||
#![cfg(any(not(debug_assertions), test))]
|
||||
#![cfg_attr(test, allow(dead_code))]
|
||||
|
||||
use crate::history_cell::padded_emoji;
|
||||
use crate::key_hint;
|
||||
@@ -11,8 +12,12 @@ use crate::selection_list::selection_option_row;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::PromptedUpdate;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::update_action::UpdateActionStatus;
|
||||
use crate::update_action::UpdateBlocker;
|
||||
use crate::updates;
|
||||
use crate::updates::UpgradeNotice;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -29,20 +34,55 @@ use tokio_stream::StreamExt;
|
||||
|
||||
pub(crate) enum UpdatePromptOutcome {
|
||||
Continue,
|
||||
RunUpdate(UpdateAction),
|
||||
RunUpdate(PromptedUpdate),
|
||||
StartRepairSession(String),
|
||||
}
|
||||
|
||||
pub(crate) async fn run_update_prompt_if_needed(
|
||||
tui: &mut Tui,
|
||||
config: &Config,
|
||||
) -> Result<UpdatePromptOutcome> {
|
||||
let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else {
|
||||
return Ok(UpdatePromptOutcome::Continue);
|
||||
};
|
||||
let Some(update_action) = crate::update_action::get_update_action() else {
|
||||
let Some(notice) = updates::get_upgrade_notice_for_popup(config) else {
|
||||
return Ok(UpdatePromptOutcome::Continue);
|
||||
};
|
||||
|
||||
match notice {
|
||||
UpgradeNotice::Available(latest_version) => {
|
||||
let update_action = match crate::update_action::get_update_action_status() {
|
||||
UpdateActionStatus::Ready(update_action) => update_action,
|
||||
UpdateActionStatus::Blocked(blocker) => {
|
||||
return run_remediation_prompt(
|
||||
tui,
|
||||
config,
|
||||
latest_version,
|
||||
RemediationPromptReason::Blocked(blocker),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
UpdateActionStatus::Unavailable => return Ok(UpdatePromptOutcome::Continue),
|
||||
};
|
||||
run_standard_update_prompt(tui, config, latest_version, update_action).await
|
||||
}
|
||||
UpgradeNotice::RemediationNeeded(latest_version) => {
|
||||
run_remediation_prompt(
|
||||
tui,
|
||||
config,
|
||||
latest_version.clone(),
|
||||
RemediationPromptReason::NoOpUpdate {
|
||||
latest_version: latest_version.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_standard_update_prompt(
|
||||
tui: &mut Tui,
|
||||
config: &Config,
|
||||
latest_version: String,
|
||||
update_action: UpdateAction,
|
||||
) -> Result<UpdatePromptOutcome> {
|
||||
let mut screen =
|
||||
UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action);
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
@@ -71,7 +111,11 @@ pub(crate) async fn run_update_prompt_if_needed(
|
||||
match screen.selection() {
|
||||
Some(UpdateSelection::UpdateNow) => {
|
||||
tui.terminal.clear()?;
|
||||
Ok(UpdatePromptOutcome::RunUpdate(update_action))
|
||||
Ok(UpdatePromptOutcome::RunUpdate(PromptedUpdate {
|
||||
action: update_action,
|
||||
target_version: latest_version,
|
||||
version_file: updates::version_filepath(config),
|
||||
}))
|
||||
}
|
||||
Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue),
|
||||
Some(UpdateSelection::DontRemind) => {
|
||||
@@ -83,6 +127,49 @@ pub(crate) async fn run_update_prompt_if_needed(
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_remediation_prompt(
|
||||
tui: &mut Tui,
|
||||
config: &Config,
|
||||
latest_version: String,
|
||||
reason: RemediationPromptReason,
|
||||
) -> Result<UpdatePromptOutcome> {
|
||||
let mut screen = RemediationPromptScreen::new(tui.frame_requester(), reason.clone());
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
})?;
|
||||
|
||||
let events = tui.event_stream();
|
||||
tokio::pin!(events);
|
||||
|
||||
while !screen.is_done() {
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Draw | TuiEvent::Resize => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
})?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match screen.selection() {
|
||||
Some(RemediationSelection::HelpMeFixThis) => Ok(UpdatePromptOutcome::StartRepairSession(
|
||||
reason.repair_prompt(),
|
||||
)),
|
||||
Some(RemediationSelection::DontRemind) => {
|
||||
if let Err(err) = updates::dismiss_version(config, &latest_version).await {
|
||||
tracing::error!("Failed to persist update remediation dismissal: {err}");
|
||||
}
|
||||
Ok(UpdatePromptOutcome::Continue)
|
||||
}
|
||||
Some(RemediationSelection::Later) | None => Ok(UpdatePromptOutcome::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum UpdateSelection {
|
||||
UpdateNow,
|
||||
@@ -208,21 +295,23 @@ impl WidgetRef for &UpdatePromptScreen {
|
||||
.dim()
|
||||
.underlined(),
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
.inset(Insets::tlbr(
|
||||
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||||
)),
|
||||
);
|
||||
column.push("");
|
||||
column.push(selection_option_row(
|
||||
0,
|
||||
/*index*/ 0,
|
||||
format!("Update now (runs `{update_command}`)"),
|
||||
self.highlighted == UpdateSelection::UpdateNow,
|
||||
));
|
||||
column.push(selection_option_row(
|
||||
1,
|
||||
/*index*/ 1,
|
||||
"Skip".to_string(),
|
||||
self.highlighted == UpdateSelection::NotNow,
|
||||
));
|
||||
column.push(selection_option_row(
|
||||
2,
|
||||
/*index*/ 2,
|
||||
"Skip until next version".to_string(),
|
||||
self.highlighted == UpdateSelection::DontRemind,
|
||||
));
|
||||
@@ -233,12 +322,186 @@ impl WidgetRef for &UpdatePromptScreen {
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to continue".dim(),
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
.inset(Insets::tlbr(
|
||||
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||||
)),
|
||||
);
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum RemediationPromptReason {
|
||||
Blocked(UpdateBlocker),
|
||||
NoOpUpdate { latest_version: String },
|
||||
}
|
||||
|
||||
impl RemediationPromptReason {
|
||||
fn lines(&self) -> Vec<String> {
|
||||
match self {
|
||||
Self::Blocked(blocker) => blocker.remediation_lines(),
|
||||
Self::NoOpUpdate { latest_version } => vec![
|
||||
"The previous update command completed, but this Codex executable did not change."
|
||||
.to_string(),
|
||||
format!(
|
||||
"Codex is still running {} while {latest_version} is available.",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
),
|
||||
"Check which `codex` your shell runs before trying again.".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn repair_prompt(&self) -> String {
|
||||
match self {
|
||||
Self::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
|
||||
running_package_root,
|
||||
npm_package_root,
|
||||
}) => format!(
|
||||
"Help me fix my Codex install. Codex is currently running from {}, but `npm install -g @openai/codex@latest` would update {}. Please inspect my shell PATH and Codex installs, explain the safest fix, and ask before making any destructive changes.",
|
||||
running_package_root.display(),
|
||||
npm_package_root.display(),
|
||||
),
|
||||
Self::NoOpUpdate { latest_version } => format!(
|
||||
"Help me fix my Codex install. A previous update command completed, but this Codex executable is still running {} while {latest_version} is available. Please inspect my shell PATH and Codex installs, explain the safest fix, and ask before making any destructive changes.",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RemediationSelection {
|
||||
HelpMeFixThis,
|
||||
Later,
|
||||
DontRemind,
|
||||
}
|
||||
|
||||
struct RemediationPromptScreen {
|
||||
request_frame: FrameRequester,
|
||||
reason: RemediationPromptReason,
|
||||
highlighted: RemediationSelection,
|
||||
selection: Option<RemediationSelection>,
|
||||
}
|
||||
|
||||
impl RemediationPromptScreen {
|
||||
fn new(request_frame: FrameRequester, reason: RemediationPromptReason) -> Self {
|
||||
Self {
|
||||
request_frame,
|
||||
reason,
|
||||
highlighted: RemediationSelection::HelpMeFixThis,
|
||||
selection: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||
{
|
||||
self.select(RemediationSelection::Later);
|
||||
return;
|
||||
}
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()),
|
||||
KeyCode::Char('1') => self.select(RemediationSelection::HelpMeFixThis),
|
||||
KeyCode::Char('2') => self.select(RemediationSelection::Later),
|
||||
KeyCode::Char('3') => self.select(RemediationSelection::DontRemind),
|
||||
KeyCode::Enter => self.select(self.highlighted),
|
||||
KeyCode::Esc => self.select(RemediationSelection::Later),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_highlight(&mut self, selection: RemediationSelection) {
|
||||
self.highlighted = selection;
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn select(&mut self, selection: RemediationSelection) {
|
||||
self.highlighted = selection;
|
||||
self.selection = Some(selection);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.selection.is_some()
|
||||
}
|
||||
|
||||
fn selection(&self) -> Option<RemediationSelection> {
|
||||
self.selection
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &RemediationPromptScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let mut column = ColumnRenderable::new();
|
||||
|
||||
column.push("");
|
||||
column.push(Line::from(vec![
|
||||
padded_emoji(" ✨").bold().cyan(),
|
||||
"Update needs attention".bold(),
|
||||
]));
|
||||
column.push("");
|
||||
for line in self.reason.lines() {
|
||||
column.push(Line::from(line).inset(Insets::tlbr(
|
||||
/*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,
|
||||
)));
|
||||
}
|
||||
column.push("");
|
||||
column.push(remediation_selection_line(
|
||||
/*index*/ 0,
|
||||
"Help me fix this",
|
||||
self.highlighted == RemediationSelection::HelpMeFixThis,
|
||||
));
|
||||
column.push(remediation_selection_line(
|
||||
/*index*/ 1,
|
||||
"Later",
|
||||
self.highlighted == RemediationSelection::Later,
|
||||
));
|
||||
column.push(remediation_selection_line(
|
||||
/*index*/ 2,
|
||||
"Don't remind me about this version",
|
||||
self.highlighted == RemediationSelection::DontRemind,
|
||||
));
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn remediation_selection_line(index: usize, label: &str, selected: bool) -> Line<'static> {
|
||||
let line = if selected {
|
||||
return Line::from(vec![
|
||||
format!("› {}. ", index + 1).cyan(),
|
||||
label.to_string().cyan(),
|
||||
]);
|
||||
} else {
|
||||
format!(" {}. {label}", index + 1)
|
||||
};
|
||||
Line::from(line)
|
||||
}
|
||||
|
||||
impl RemediationSelection {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::HelpMeFixThis => Self::Later,
|
||||
Self::Later => Self::DontRemind,
|
||||
Self::DontRemind => Self::HelpMeFixThis,
|
||||
}
|
||||
}
|
||||
|
||||
fn prev(self) -> Self {
|
||||
match self {
|
||||
Self::HelpMeFixThis => Self::DontRemind,
|
||||
Self::Later => Self::HelpMeFixThis,
|
||||
Self::DontRemind => Self::Later,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -260,7 +523,8 @@ mod tests {
|
||||
#[test]
|
||||
fn update_prompt_snapshot() {
|
||||
let screen = new_prompt();
|
||||
let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal");
|
||||
let mut terminal =
|
||||
Terminal::new(VT100Backend::new(/*width*/ 80, /*height*/ 12)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||
.expect("render update prompt");
|
||||
@@ -310,4 +574,40 @@ mod tests {
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(screen.highlighted, UpdateSelection::UpdateNow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_remediation_prompt_snapshot() {
|
||||
let screen = RemediationPromptScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
RemediationPromptReason::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
|
||||
running_package_root: "/prefix-a/lib/node_modules/@openai/codex".into(),
|
||||
npm_package_root: "/prefix-b/lib/node_modules/@openai/codex".into(),
|
||||
}),
|
||||
);
|
||||
let mut terminal =
|
||||
Terminal::new(VT100Backend::new(/*width*/ 96, /*height*/ 12)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||
.expect("render update remediation prompt");
|
||||
insta::assert_snapshot!("update_remediation_prompt_modal", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remediation_prompt_help_selection_starts_repair_flow() {
|
||||
let reason = RemediationPromptReason::Blocked(UpdateBlocker::NpmGlobalRootMismatch {
|
||||
running_package_root: "/prefix-a/lib/node_modules/@openai/codex".into(),
|
||||
npm_package_root: "/prefix-b/lib/node_modules/@openai/codex".into(),
|
||||
});
|
||||
|
||||
assert!(
|
||||
reason
|
||||
.repair_prompt()
|
||||
.contains("/prefix-a/lib/node_modules/@openai/codex")
|
||||
);
|
||||
assert!(
|
||||
reason
|
||||
.repair_prompt()
|
||||
.contains("/prefix-b/lib/node_modules/@openai/codex")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#![cfg(not(debug_assertions))]
|
||||
#![cfg(any(not(debug_assertions), test))]
|
||||
#![cfg_attr(test, allow(dead_code))]
|
||||
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::npm_registry;
|
||||
use crate::npm_registry::NpmPackageInfo;
|
||||
use crate::update_action;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::update_action::UpdateActionStatus;
|
||||
use crate::update_action::UpdateBlocker;
|
||||
use crate::update_versions::extract_version_from_latest_tag;
|
||||
use crate::update_versions::is_newer;
|
||||
use crate::update_versions::is_source_build_version;
|
||||
@@ -19,6 +22,24 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UpgradeNotice {
|
||||
Available(String),
|
||||
RemediationNeeded(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UpgradeHistoryNotice {
|
||||
Available {
|
||||
latest_version: String,
|
||||
update_action: Option<UpdateAction>,
|
||||
},
|
||||
BlockedWarning(UpdateBlocker),
|
||||
NoOpUpdateWarning {
|
||||
latest_version: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
|
||||
return None;
|
||||
@@ -42,13 +63,52 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
});
|
||||
}
|
||||
|
||||
info.and_then(|info| {
|
||||
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
|
||||
Some(info.latest_version)
|
||||
} else {
|
||||
None
|
||||
info.and_then(latest_upgrade_version)
|
||||
}
|
||||
|
||||
pub fn get_upgrade_notice_for_history(
|
||||
config: &Config,
|
||||
prompt_launch: bool,
|
||||
action_status: UpdateActionStatus,
|
||||
) -> Option<UpgradeHistoryNotice> {
|
||||
let latest = get_upgrade_version(config)?;
|
||||
let version_file = version_filepath(config);
|
||||
let info = read_version_info(&version_file).ok();
|
||||
|
||||
if info
|
||||
.as_ref()
|
||||
.is_some_and(|info| should_show_prompt_update_remediation(info, &latest))
|
||||
{
|
||||
if prompt_launch
|
||||
&& info
|
||||
.as_ref()
|
||||
.and_then(|info| info.no_op_inline_notice_shown_version.as_deref())
|
||||
!= Some(latest.as_str())
|
||||
{
|
||||
if let Err(err) = record_no_op_inline_notice_shown(&version_file, &latest) {
|
||||
tracing::warn!("Failed to persist no-op update inline notice state: {err}");
|
||||
}
|
||||
return Some(UpgradeHistoryNotice::NoOpUpdateWarning {
|
||||
latest_version: latest,
|
||||
});
|
||||
}
|
||||
})
|
||||
return None;
|
||||
}
|
||||
|
||||
match action_status {
|
||||
UpdateActionStatus::Ready(update_action) => Some(UpgradeHistoryNotice::Available {
|
||||
latest_version: latest,
|
||||
update_action: Some(update_action),
|
||||
}),
|
||||
UpdateActionStatus::Unavailable => Some(UpgradeHistoryNotice::Available {
|
||||
latest_version: latest,
|
||||
update_action: None,
|
||||
}),
|
||||
UpdateActionStatus::Blocked(blocker) if prompt_launch => {
|
||||
Some(UpgradeHistoryNotice::BlockedWarning(blocker))
|
||||
}
|
||||
UpdateActionStatus::Blocked(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -58,6 +118,18 @@ struct VersionInfo {
|
||||
last_checked_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
dismissed_version: Option<String>,
|
||||
#[serde(default)]
|
||||
successful_prompt_update: Option<SuccessfulPromptUpdate>,
|
||||
#[serde(default)]
|
||||
suppressed_version: Option<String>,
|
||||
#[serde(default)]
|
||||
no_op_inline_notice_shown_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
struct SuccessfulPromptUpdate {
|
||||
from_version: String,
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
const VERSION_FILENAME: &str = "version.json";
|
||||
@@ -75,7 +147,7 @@ struct HomebrewCaskInfo {
|
||||
version: String,
|
||||
}
|
||||
|
||||
fn version_filepath(config: &Config) -> PathBuf {
|
||||
pub(crate) fn version_filepath(config: &Config) -> PathBuf {
|
||||
config.codex_home.join(VERSION_FILENAME).into_path_buf()
|
||||
}
|
||||
|
||||
@@ -113,12 +185,20 @@ async fn check_for_update(version_file: &Path, action: Option<UpdateAction>) ->
|
||||
}
|
||||
};
|
||||
|
||||
// Preserve any previously dismissed version if present.
|
||||
// Preserve local prompt state across version refreshes.
|
||||
let prev_info = read_version_info(version_file).ok();
|
||||
let info = VersionInfo {
|
||||
latest_version,
|
||||
last_checked_at: Utc::now(),
|
||||
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
|
||||
dismissed_version: prev_info.as_ref().and_then(|p| p.dismissed_version.clone()),
|
||||
successful_prompt_update: prev_info
|
||||
.as_ref()
|
||||
.and_then(|p| p.successful_prompt_update.clone()),
|
||||
suppressed_version: prev_info
|
||||
.as_ref()
|
||||
.and_then(|p| p.suppressed_version.clone()),
|
||||
no_op_inline_notice_shown_version: prev_info
|
||||
.and_then(|p| p.no_op_inline_notice_shown_version),
|
||||
};
|
||||
|
||||
let json_line = format!("{}\n", serde_json::to_string(&info)?);
|
||||
@@ -142,22 +222,27 @@ async fn fetch_latest_github_release_version() -> anyhow::Result<String> {
|
||||
extract_version_from_latest_tag(&latest_tag_name)
|
||||
}
|
||||
|
||||
/// Returns the latest version to show in a popup, if it should be shown.
|
||||
/// Returns the upgrade notice to show in a popup, if one should be shown.
|
||||
/// This respects the user's dismissal choice for the current latest version.
|
||||
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
|
||||
pub fn get_upgrade_notice_for_popup(config: &Config) -> Option<UpgradeNotice> {
|
||||
if !config.check_for_update_on_startup || is_source_build_version(CODEX_CLI_VERSION) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_file = version_filepath(config);
|
||||
let latest = get_upgrade_version(config)?;
|
||||
// If the user dismissed this exact version previously, do not show the popup.
|
||||
if let Ok(info) = read_version_info(&version_file)
|
||||
&& info.dismissed_version.as_deref() == Some(latest.as_str())
|
||||
{
|
||||
let Ok(info) = read_version_info(&version_file) else {
|
||||
return Some(UpgradeNotice::Available(latest));
|
||||
};
|
||||
|
||||
if info.dismissed_version.as_deref() == Some(latest.as_str()) {
|
||||
return None;
|
||||
}
|
||||
Some(latest)
|
||||
if should_show_prompt_update_remediation(&info, &latest) {
|
||||
Some(UpgradeNotice::RemediationNeeded(latest))
|
||||
} else {
|
||||
Some(UpgradeNotice::Available(latest))
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a dismissal for the current latest version so we don't show
|
||||
@@ -176,3 +261,159 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
|
||||
tokio::fs::write(version_file, json_line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist a successful prompt-triggered update attempt so the next launch can
|
||||
/// detect whether the running executable actually changed.
|
||||
pub fn record_successful_prompt_update_attempt(
|
||||
version_file: &Path,
|
||||
target_version: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut info = read_version_info(version_file)?;
|
||||
info.successful_prompt_update = Some(SuccessfulPromptUpdate {
|
||||
from_version: CODEX_CLI_VERSION.to_string(),
|
||||
target_version: target_version.to_string(),
|
||||
});
|
||||
write_version_info_sync(version_file, &info)
|
||||
}
|
||||
|
||||
fn record_no_op_inline_notice_shown(version_file: &Path, version: &str) -> anyhow::Result<()> {
|
||||
let mut info = read_version_info(version_file)?;
|
||||
info.no_op_inline_notice_shown_version = Some(version.to_string());
|
||||
write_version_info_sync(version_file, &info)
|
||||
}
|
||||
|
||||
/// Suppress future notices for the current latest version after we explain a
|
||||
/// likely no-op update once.
|
||||
pub async fn suppress_version_after_remediation(
|
||||
config: &Config,
|
||||
version: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let version_file = version_filepath(config);
|
||||
let mut info = match read_version_info(&version_file) {
|
||||
Ok(info) => info,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
info.suppressed_version = Some(version.to_string());
|
||||
let json_line = format!("{}\n", serde_json::to_string(&info)?);
|
||||
if let Some(parent) = version_file.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(version_file, json_line).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_show_prompt_update_remediation(info: &VersionInfo, latest: &str) -> bool {
|
||||
matches!(
|
||||
info.successful_prompt_update.as_ref(),
|
||||
Some(SuccessfulPromptUpdate {
|
||||
from_version,
|
||||
target_version,
|
||||
}) if from_version == CODEX_CLI_VERSION && target_version == latest
|
||||
)
|
||||
}
|
||||
|
||||
fn latest_upgrade_version(info: VersionInfo) -> Option<String> {
|
||||
if info.suppressed_version.as_deref() == Some(info.latest_version.as_str()) {
|
||||
return None;
|
||||
}
|
||||
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(/*default*/ false) {
|
||||
Some(info.latest_version)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn write_version_info_sync(version_file: &Path, info: &VersionInfo) -> anyhow::Result<()> {
|
||||
let json_line = format!("{}\n", serde_json::to_string(info)?);
|
||||
if let Some(parent) = version_file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(version_file, json_line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn version_info(latest_version: &str) -> VersionInfo {
|
||||
VersionInfo {
|
||||
latest_version: latest_version.to_string(),
|
||||
last_checked_at: Utc::now(),
|
||||
dismissed_version: None,
|
||||
successful_prompt_update: None,
|
||||
suppressed_version: None,
|
||||
no_op_inline_notice_shown_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_prompt_update_for_current_binary_triggers_remediation() {
|
||||
let mut info = version_info("9.9.9");
|
||||
info.successful_prompt_update = Some(SuccessfulPromptUpdate {
|
||||
from_version: CODEX_CLI_VERSION.to_string(),
|
||||
target_version: "9.9.9".to_string(),
|
||||
});
|
||||
|
||||
assert!(should_show_prompt_update_remediation(&info, "9.9.9"));
|
||||
assert!(!should_show_prompt_update_remediation(&info, "9.9.10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remediation_suppression_hides_the_same_latest_version() {
|
||||
let mut info = version_info("9.9.9");
|
||||
info.suppressed_version = Some("9.9.9".to_string());
|
||||
|
||||
assert_eq!(latest_upgrade_version(info), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_latest_version_ignores_stale_remediation_suppression() {
|
||||
let mut info = version_info("9.9.10");
|
||||
info.suppressed_version = Some("9.9.9".to_string());
|
||||
|
||||
assert_eq!(latest_upgrade_version(info), Some("9.9.10".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_prompt_update_attempt_is_persisted() -> anyhow::Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let version_file = tempdir.path().join(VERSION_FILENAME);
|
||||
write_version_info_sync(&version_file, &version_info("9.9.9"))?;
|
||||
|
||||
record_successful_prompt_update_attempt(&version_file, "9.9.9")?;
|
||||
|
||||
let info = read_version_info(&version_file)?;
|
||||
assert_eq!(
|
||||
info.successful_prompt_update,
|
||||
Some(SuccessfulPromptUpdate {
|
||||
from_version: CODEX_CLI_VERSION.to_string(),
|
||||
target_version: "9.9.9".to_string(),
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_inline_notice_marker_is_persisted_without_clearing_remediation() -> anyhow::Result<()>
|
||||
{
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let version_file = tempdir.path().join(VERSION_FILENAME);
|
||||
let mut info = version_info("9.9.9");
|
||||
info.successful_prompt_update = Some(SuccessfulPromptUpdate {
|
||||
from_version: CODEX_CLI_VERSION.to_string(),
|
||||
target_version: "9.9.9".to_string(),
|
||||
});
|
||||
write_version_info_sync(&version_file, &info)?;
|
||||
|
||||
record_no_op_inline_notice_shown(&version_file, "9.9.9")?;
|
||||
|
||||
let info = read_version_info(&version_file)?;
|
||||
assert_eq!(
|
||||
info.no_op_inline_notice_shown_version.as_deref(),
|
||||
Some("9.9.9")
|
||||
);
|
||||
assert!(should_show_prompt_update_remediation(&info, "9.9.9"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user