Skip to main content

magi/
updater.rs

1//! Self-update, via `kaishin`.
2//!
3//! A magi run takes minutes of agent latency, so a background release check
4//! costs nothing measurable: it is spawned on the same tokio runtime as the
5//! command, overlaps it, and is drained with a bounded wait at shutdown. It
6//! never delays the graph.
7use std::path::PathBuf;
8use std::time::Duration;
9
10use anyhow::Result;
11
12use crate::config::{Update, UpdateMode};
13
14/// Env kill-switch. Any non-empty value other than `0` / `false` disables the
15/// background check, and it is read before the config so a broken `magi.toml`
16/// cannot force a network call.
17pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
18
19/// Default interval between checks.
20pub fn default_interval() -> Duration {
21    kaishin::default_interval()
22}
23
24/// Is the background check switched off by the environment?
25pub fn disabled_by_env() -> bool {
26    match std::env::var(NO_AUTOUPDATE_ENV) {
27        Ok(v) => {
28            let v = v.trim();
29            !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
30        }
31        Err(_) => false,
32    }
33}
34
35/// GitHub owner.
36const OWNER: &str = "yukimemi";
37/// GitHub repository — *not* `CARGO_PKG_NAME`, which is the published package.
38const REPO: &str = "magi";
39/// Binary inside the release asset.
40const BIN: &str = "magi";
41/// Published package name, for kaishin's `cargo install` fallback.
42const CRATE: &str = "magi-cli";
43
44/// kaishin options.
45///
46/// All four names are spelled out because three of them differ from
47/// `CARGO_PKG_NAME`: the package is `magi-cli` (the short name is a squatted
48/// placeholder on crates.io) while the repo, the binary and the library are
49/// `magi`. Deriving any of these from `CARGO_PKG_NAME` would send the updater
50/// looking for a `yukimemi/magi-cli` repository that does not exist.
51fn options() -> kaishin::KaishinOptions {
52    kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
53}
54
55/// Throttle bookkeeping is transient, so it belongs in the cache dir rather
56/// than beside the run history in the data dir.
57fn state_path() -> Option<PathBuf> {
58    dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
59}
60
61/// `magi self-update`.
62pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
63    let opts = kaishin::UpdateOptions::new()
64        .yes(yes)
65        .check_only(check_only)
66        .non_interactive(non_interactive);
67    kaishin::run_self_update(&options(), opts).await
68}
69
70/// A background update check, resolved at shutdown.
71pub enum Pending {
72    /// A previous run already found a newer release; just print the banner.
73    Cached {
74        /// For [`Checker::format_banner`].
75        checker: Checker,
76        /// The release found earlier.
77        latest: kaishin::LatestRelease,
78    },
79    /// A notify-mode check is in flight.
80    Notify {
81        /// For [`Checker::format_banner`].
82        checker: Checker,
83        /// The spawned task.
84        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
85    },
86    /// An install-mode update is in flight.
87    Install {
88        /// The spawned task.
89        handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
90    },
91}
92
93/// Throttled release checker.
94#[derive(Clone)]
95pub struct Checker {
96    inner: kaishin::Checker,
97}
98
99impl Checker {
100    /// Build a checker honouring `cfg`.
101    pub fn new(cfg: &Update) -> Option<Self> {
102        let mut inner = kaishin::Checker::new(BIN, options());
103        if let Some(path) = state_path() {
104            inner = inner.state_path(path);
105        }
106        let interval = cfg
107            .interval
108            .as_deref()
109            .and_then(|s| kaishin::parse_interval(s).ok())
110            .unwrap_or_else(default_interval);
111        Some(Self {
112            inner: inner.interval(interval),
113        })
114    }
115
116    /// Is a check due?
117    pub fn should_check(&self) -> bool {
118        self.inner.should_check()
119    }
120
121    /// A newer release already known from a previous run.
122    pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
123        self.inner.cached_update()
124    }
125
126    /// One-line "a newer version exists" banner.
127    pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
128        self.inner.format_banner(latest)
129    }
130}
131
132/// Spawn the background check for `cfg`, unless it is switched off.
133pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
134    if disabled_by_env() || cfg.mode == UpdateMode::Off {
135        return None;
136    }
137    let checker = Checker::new(cfg)?;
138    match cfg.mode {
139        UpdateMode::Off => None,
140        UpdateMode::Notify => {
141            if !checker.should_check() {
142                let latest = checker.cached_update()?;
143                return Some(Pending::Cached { checker, latest });
144            }
145            let inner = checker.inner.clone();
146            let handle = rt.spawn(async move { inner.check_and_save().await });
147            Some(Pending::Notify { checker, handle })
148        }
149        UpdateMode::Install => {
150            let inner = checker.inner.clone();
151            let handle = rt.spawn(async move { inner.auto_update().await });
152            Some(Pending::Install { handle })
153        }
154    }
155}
156
157/// Drain a pending check and print at most one line.
158///
159/// Bounded on purpose: a slow network must never hold up the exit of a command
160/// that already did its work.
161pub async fn finalize(pending: Option<Pending>, budget: Duration) {
162    let Some(pending) = pending else {
163        return;
164    };
165    match pending {
166        Pending::Cached { checker, latest } => {
167            eprintln!("{}", checker.format_banner(&latest));
168        }
169        Pending::Notify { checker, handle } => {
170            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
171                eprintln!("{}", checker.format_banner(&latest));
172            }
173        }
174        Pending::Install { handle } => {
175            if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
176                eprintln!("magi updated itself to {}", latest.tag_name);
177            }
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn env_kill_switch_semantics() {
188        // SAFETY: single-threaded test, no other thread reads the variable.
189        unsafe {
190            std::env::remove_var(NO_AUTOUPDATE_ENV);
191        }
192        assert!(!disabled_by_env());
193        for (value, disabled) in [
194            ("1", true),
195            ("true", true),
196            ("yes", true),
197            ("0", false),
198            ("false", false),
199            ("FALSE", false),
200            ("", false),
201            ("  ", false),
202        ] {
203            unsafe {
204                std::env::set_var(NO_AUTOUPDATE_ENV, value);
205            }
206            assert_eq!(
207                disabled_by_env(),
208                disabled,
209                "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
210                if disabled { "" } else { "not" }
211            );
212        }
213        unsafe {
214            std::env::remove_var(NO_AUTOUPDATE_ENV);
215        }
216    }
217
218    #[test]
219    fn off_mode_never_spawns() {
220        let rt = tokio::runtime::Builder::new_current_thread()
221            .enable_all()
222            .build()
223            .unwrap();
224        let cfg = Update {
225            mode: UpdateMode::Off,
226            interval: None,
227        };
228        assert!(spawn(&cfg, rt.handle()).is_none());
229    }
230
231    #[test]
232    fn state_path_lives_under_the_cache_dir() {
233        let path = state_path().expect("a cache dir on every supported platform");
234        assert!(path.ends_with("magi/last_update_check.json"));
235        let data = dirs::data_local_dir().unwrap_or_default();
236        assert!(
237            !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
238            "throttle state must not sit in the run history directory"
239        );
240    }
241
242    #[tokio::test]
243    async fn finalize_of_nothing_is_a_no_op() {
244        finalize(None, Duration::from_millis(1)).await;
245    }
246}