Skip to main content

objectiveai_cli/command/
update.rs

1//! `update` — bare-naked streaming handler.
2//!
3//! Refreshes every shipped binary from the latest GitHub release by
4//! downloading a single per-platform zip
5//! (`objectiveai-<version>-<os>-<arch>.zip`) and replacing the contents
6//! of the machine-wide `bin/` directory wholesale. Emits one
7//! [`ResponseItem`] per stage as the run progresses.
8//!
9//! Layout on disk (resolved via `ctx.filesystem.bin_dir()` — every
10//! binary is machine-wide, shared across states):
11//!
12//! ```text
13//! <bin_dir>/objectiveai{.exe}                       ← cli
14//! <bin_dir>/objectiveai-api{.exe}
15//! <bin_dir>/objectiveai-viewer{.exe}
16//! <bin_dir>/objectiveai-mcp{.exe}
17//! <bin_dir>/objectiveai-db{.exe}
18//! <bin_dir>/objectiveai-claude-agent-sdk-runner{.exe}
19//! <bin_dir>/objectiveai-codex-sdk-runner{.exe}
20//! <bin_dir>/objectiveai-mcp-laboratory   (always musl-linux; no .exe)
21//! ```
22//!
23//! Flow:
24//! 1. Resolve the single zip asset for this `(os, arch)`. A missing zip
25//!    emits [`ResponseSkipReason::IncompleteRelease`].
26//! 2. Download the zip to a temp path (outside `bin/`, so the wipe
27//!    below can't clobber it).
28//! 3. Kill the running servers in-process: the machine-wide `api` (its
29//!    lock lives at `<bin_dir>/locks`) and the per-state `db` / `viewer`
30//!    across every state.
31//! 4. Rename the running updater aside (Windows can't overwrite a
32//!    running `.exe`; renaming frees the name — the process keeps
33//!    running from the renamed file).
34//! 5. Wipe `bin/` keeping only `plugins/`, `tools/`, and `config.json`
35//!    (best-effort — a still-running server/`.old` that won't delete is
36//!    skipped). `pg-bin/` is wiped; postgres re-extracts on next `db`
37//!    spawn.
38//! 6. Unzip the download into `bin/`. Each binary is written via the
39//!    same rename-aside swap so a still-locked straggler doesn't fail
40//!    the extraction.
41//!
42//! Caveat: `db`/`viewer` are killed across every state, but a server
43//! that comes back (or a binary still locked at unzip time) is left as
44//! a `.old`; the cli's own `.old` is swept on the next invocation.
45
46use std::path::{Path, PathBuf};
47use std::pin::Pin;
48use std::time::Duration;
49
50use futures::Stream;
51use objectiveai_sdk::cli::command::update::{Request, ResponseItem, ResponseSkipReason};
52
53use crate::context::Context;
54use crate::error::Error;
55
56type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
57
58const RELEASES_API: &str =
59    "https://api.github.com/repos/ObjectiveAI/objectiveai/releases/latest";
60const METADATA_TIMEOUT: Duration = Duration::from_secs(10);
61// The per-platform zip bundles every binary (objectiveai-db alone
62// carries postgres at ~163 MB), so the cap is generous to tolerate the
63// full archive on slower links.
64const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(600);
65
66/// Entries under `bin/` the wipe preserves: user-installed plugins and
67/// tools, plus the machine-wide config.
68const WIPE_KEEP: &[&str] = &["plugins", "tools", "config.json"];
69
70pub async fn execute(ctx: &Context, _request: Request) -> Result<ItemStream, Error> {
71    let (tx, rx) = tokio::sync::mpsc::channel::<Result<ResponseItem, Error>>(8);
72    let bin_dir = ctx.filesystem.bin_dir();
73    // db/viewer locks are per-state under <dir>/state/<name>/locks.
74    let states_root = ctx.filesystem.dir().join("state");
75    // The GitHub credential lives in the on-disk json config only
76    // (`api config github-authorization set`), not the env Config.
77    let github_authorization = ctx
78        .filesystem
79        .read_config_view(objectiveai_sdk::cli::command::GetScope::Final)
80        .await?
81        .api()
82        .get_github_authorization()
83        .map(String::from);
84
85    tokio::spawn(async move {
86        if let Err(e) = run(
87            &bin_dir,
88            &states_root,
89            github_authorization.as_deref(),
90            &tx,
91        )
92        .await
93        {
94            let _ = tx.send(Err(e)).await;
95        }
96    });
97
98    Ok(Box::pin(futures::stream::unfold(rx, |mut rx| async move {
99        rx.recv().await.map(|item| (item, rx))
100    })))
101}
102
103async fn run(
104    bin_dir: &Path,
105    states_root: &Path,
106    github_authorization: Option<&str>,
107    tx: &tokio::sync::mpsc::Sender<Result<ResponseItem, Error>>,
108) -> Result<(), Error> {
109    // Dev tree refuses to self-update — overwriting a `cargo run`
110    // output would clobber an in-tree build.
111    let current_exe = std::env::current_exe()
112        .map_err(|e| Error::Updater(format!("could not locate current binary: {e}")))?;
113    if looks_like_dev_tree(&current_exe) {
114        let _ = tx
115            .send(Ok(ResponseItem::Skipped {
116                reason: ResponseSkipReason::DevTree,
117            }))
118            .await;
119        return Ok(());
120    }
121
122    let Some((os, arch, ext)) = platform_triple() else {
123        let _ = tx
124            .send(Ok(ResponseItem::Skipped {
125                reason: ResponseSkipReason::UnsupportedPlatform,
126            }))
127            .await;
128        return Ok(());
129    };
130
131    // Best-effort cleanup of any stale `.exe.old` from a prior Windows
132    // swap before we begin.
133    sweep_stale_old(&current_exe);
134
135    let local = env!("CARGO_PKG_VERSION");
136    let local_ver = semver::Version::parse(local)
137        .map_err(|e| Error::Updater(format!("semver parse: {e}")))?;
138
139    // Fetch the latest release metadata.
140    let http = reqwest::Client::new();
141    let auth = github_authorization_header(github_authorization);
142
143    let release: Release = {
144        let mut req = http
145            .get(RELEASES_API)
146            .header("User-Agent", format!("objectiveai/{local}"))
147            .header("Accept", "application/vnd.github+json")
148            .timeout(METADATA_TIMEOUT);
149        if let Some(ref h) = auth {
150            req = req.header("Authorization", h);
151        }
152        let resp = req
153            .send()
154            .await
155            .map_err(|e| Error::Updater(format!("http: {e}")))?;
156        let status = resp.status();
157        if !status.is_success() {
158            return Err(Error::Updater(format!("github returned status {status}")));
159        }
160        let body = resp
161            .bytes()
162            .await
163            .map_err(|e| Error::Updater(format!("http: {e}")))?;
164        serde_json::from_slice(&body)
165            .map_err(|e| Error::Updater(format!("malformed release metadata: {e}")))?
166    };
167
168    // Compare versions. Tag is `v<X.Y.Z>` by repo convention. The asset
169    // name embeds this version, so it has to be resolved before we can
170    // look the asset up.
171    let remote_str = release
172        .tag_name
173        .strip_prefix('v')
174        .unwrap_or(&release.tag_name);
175    let remote = semver::Version::parse(remote_str)
176        .map_err(|e| Error::Updater(format!("semver parse: {e}")))?;
177
178    // One asset per platform, version-stamped:
179    // objectiveai-<version>-<os>-<arch>.zip.
180    let asset_name = format!("objectiveai-{remote_str}-{os}-{arch}.zip");
181
182    let _ = tx
183        .send(Ok(ResponseItem::Checking {
184            asset_name: asset_name.clone(),
185            current_version: local.to_string(),
186        }))
187        .await;
188
189    // The platform's zip must be present, or there's nothing to install.
190    let Some(asset) = release.assets.iter().find(|a| a.name == asset_name) else {
191        let _ = tx
192            .send(Ok(ResponseItem::Skipped {
193                reason: ResponseSkipReason::IncompleteRelease,
194            }))
195            .await;
196        return Ok(());
197    };
198
199    if remote <= local_ver {
200        let _ = tx
201            .send(Ok(ResponseItem::UpToDate {
202                current_version: local_ver.to_string(),
203                remote_version: remote.to_string(),
204            }))
205            .await;
206        return Ok(());
207    }
208
209    let _ = tx
210        .send(Ok(ResponseItem::Found {
211            current_version: local_ver.to_string(),
212            remote_version: remote.to_string(),
213            asset_name: asset_name.clone(),
214            url: asset.browser_download_url.clone(),
215        }))
216        .await;
217
218    // Download the zip to a temp path OUTSIDE bin/ — the wipe below
219    // clears bin/, so a staged copy in there would be deleted.
220    let zip_path =
221        std::env::temp_dir().join(format!("objectiveai-update-{}.zip", std::process::id()));
222    if let Err(e) =
223        download_to(&http, &asset.browser_download_url, auth.as_deref(), &zip_path, local).await
224    {
225        let _ = std::fs::remove_file(&zip_path);
226        return Err(e);
227    }
228
229    // Kill the running servers in-process before touching bin/. The api
230    // lock is machine-wide at <bin>/locks; db + viewer are per-state.
231    // Best-effort: a kill failure shouldn't abort the install.
232    let _ = kill_lock_owners(bin_dir.join("locks"), "api").await;
233    kill_state_servers(states_root).await;
234
235    // Free the running updater's own slot so the unzip can replace it
236    // (Windows can't overwrite a running .exe; renaming aside frees the
237    // name). On Unix the wipe can unlink the running binary directly.
238    rename_running_cli_aside(&current_exe);
239
240    // Wipe bin/ except the preserved entries, then lay down the new set.
241    wipe_bin_except(bin_dir, WIPE_KEEP);
242    std::fs::create_dir_all(bin_dir)
243        .map_err(|e| Error::Updater(format!("create bin dir: {e}")))?;
244
245    let unzip_result = unzip_into(&zip_path, bin_dir);
246    let _ = std::fs::remove_file(&zip_path);
247    unzip_result?;
248
249    sweep_stale_old(&current_exe);
250
251    let _ = tx
252        .send(Ok(ResponseItem::Installed {
253            current_version: local_ver.to_string(),
254            remote_version: remote.to_string(),
255        }))
256        .await;
257
258    Ok(())
259}
260
261#[derive(serde::Deserialize)]
262struct Release {
263    tag_name: String,
264    assets: Vec<Asset>,
265}
266
267#[derive(serde::Deserialize)]
268struct Asset {
269    name: String,
270    browser_download_url: String,
271}
272
273fn platform_triple() -> Option<(&'static str, &'static str, &'static str)> {
274    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
275    {
276        Some(("linux", "x86_64", ""))
277    }
278    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
279    {
280        Some(("linux", "aarch64", ""))
281    }
282    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
283    {
284        Some(("macos", "x86_64", ""))
285    }
286    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
287    {
288        Some(("macos", "aarch64", ""))
289    }
290    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
291    {
292        Some(("windows", "x86_64", ".exe"))
293    }
294    #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
295    {
296        Some(("windows", "aarch64", ".exe"))
297    }
298    #[cfg(not(any(
299        all(target_os = "linux", target_arch = "x86_64"),
300        all(target_os = "linux", target_arch = "aarch64"),
301        all(target_os = "macos", target_arch = "x86_64"),
302        all(target_os = "macos", target_arch = "aarch64"),
303        all(target_os = "windows", target_arch = "x86_64"),
304        all(target_os = "windows", target_arch = "aarch64"),
305    )))]
306    {
307        None
308    }
309}
310
311fn looks_like_dev_tree(current_exe: &Path) -> bool {
312    current_exe.components().any(|c| {
313        let s = c.as_os_str();
314        s == "target"
315            || s == "target-objectiveai-mcp-laboratory"
316            || s == "target-objectiveai-mcp-proxy"
317            || s == "target-objectiveai-viewer"
318    })
319}
320
321// Reused for the api lock; see crate::command::kill_helpers.
322use crate::command::kill_helpers::kill_lock_owners;
323
324/// Kill the per-state `db` and `viewer` lock owners across every state
325/// under `<dir>/state/<name>/locks`. Best-effort — failures are
326/// swallowed so a stuck state doesn't abort the install.
327async fn kill_state_servers(states_root: &Path) {
328    let mut rd = match tokio::fs::read_dir(states_root).await {
329        Ok(rd) => rd,
330        // No state dir at all → nothing running per-state.
331        Err(_) => return,
332    };
333    while let Ok(Some(entry)) = rd.next_entry().await {
334        if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
335            let locks = entry.path().join("locks");
336            let _ = kill_lock_owners(locks.clone(), "db").await;
337            let _ = kill_lock_owners(locks, "viewer").await;
338        }
339    }
340}
341
342#[cfg(windows)]
343fn rename_running_cli_aside(current_exe: &Path) {
344    // Only matters when the running binary lives in the directory the
345    // unzip will overwrite; renaming it frees its name.
346    let old = current_exe.with_extension("exe.old");
347    let _ = std::fs::remove_file(&old);
348    let _ = std::fs::rename(current_exe, &old);
349}
350
351#[cfg(not(windows))]
352fn rename_running_cli_aside(_current_exe: &Path) {
353    // Unix can unlink a running binary directly (the inode survives), so
354    // the wipe + unzip handle it with no rename needed.
355}
356
357/// Delete every entry under `bin_dir` except `keep`. Best-effort: a
358/// still-running server binary (or the renamed-aside updater) that
359/// won't delete is silently skipped — the unzip's swap handles it.
360fn wipe_bin_except(bin_dir: &Path, keep: &[&str]) {
361    let rd = match std::fs::read_dir(bin_dir) {
362        Ok(rd) => rd,
363        Err(_) => return,
364    };
365    for entry in rd.flatten() {
366        let name = entry.file_name();
367        if keep.iter().any(|k| std::ffi::OsStr::new(k) == name) {
368            continue;
369        }
370        let path = entry.path();
371        if path.is_dir() {
372            let _ = std::fs::remove_dir_all(&path);
373        } else {
374            let _ = std::fs::remove_file(&path);
375        }
376    }
377}
378
379/// Extract every file in `zip_path` into `bin_dir` (flattened to the
380/// archive's bare filenames). Each file is written to a staged
381/// `.new.<pid>` sibling, made executable on Unix, then swapped into
382/// place — so a target still locked by a running process is moved aside
383/// rather than failing the write.
384fn unzip_into(zip_path: &Path, bin_dir: &Path) -> Result<(), Error> {
385    let file = std::fs::File::open(zip_path)
386        .map_err(|e| Error::Updater(format!("open zip: {e}")))?;
387    let mut archive =
388        zip::ZipArchive::new(file).map_err(|e| Error::Updater(format!("read zip: {e}")))?;
389    let pid = std::process::id();
390
391    for i in 0..archive.len() {
392        let mut entry = archive
393            .by_index(i)
394            .map_err(|e| Error::Updater(format!("read zip entry: {e}")))?;
395        if !entry.is_file() {
396            continue;
397        }
398        let Some(rel) = entry.enclosed_name() else {
399            continue;
400        };
401        let Some(file_name) = rel.file_name() else {
402            continue;
403        };
404        let dst = bin_dir.join(file_name);
405        let staged = staged_path(&dst, pid);
406
407        {
408            let mut out = std::fs::File::create(&staged)
409                .map_err(|e| Error::Updater(format!("unzip: {e}")))?;
410            std::io::copy(&mut entry, &mut out)
411                .map_err(|e| Error::Updater(format!("unzip: {e}")))?;
412        }
413
414        #[cfg(unix)]
415        {
416            use std::os::unix::fs::PermissionsExt;
417            std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
418                .map_err(|e| Error::Updater(format!("unzip chmod: {e}")))?;
419        }
420
421        if let Err(e) = self_replace(&dst, &staged) {
422            let _ = std::fs::remove_file(&staged);
423            return Err(e);
424        }
425    }
426
427    Ok(())
428}
429
430fn staged_path(target: &Path, pid: u32) -> PathBuf {
431    let mut p = target.to_path_buf();
432    let filename = p
433        .file_name()
434        .map(|s| s.to_string_lossy().into_owned())
435        .unwrap_or_else(|| "objectiveai".to_string());
436    p.set_file_name(format!("{filename}.new.{pid}"));
437    p
438}
439
440fn github_authorization_header(caller: Option<&str>) -> Option<String> {
441    caller.map(str::trim).filter(|s| !s.is_empty()).map(|s| {
442        let bare = s.strip_prefix("Bearer ").unwrap_or(s);
443        format!("Bearer {bare}")
444    })
445}
446
447async fn download_to(
448    client: &reqwest::Client,
449    url: &str,
450    auth: Option<&str>,
451    dst: &Path,
452    version: &str,
453) -> Result<(), Error> {
454    use futures::StreamExt as _;
455    use tokio::io::AsyncWriteExt as _;
456
457    let mut req = client
458        .get(url)
459        .header("User-Agent", format!("objectiveai/{version}"))
460        .timeout(DOWNLOAD_TIMEOUT);
461    if let Some(h) = auth {
462        req = req.header("Authorization", h);
463    }
464    let resp = req
465        .send()
466        .await
467        .map_err(|e| Error::Updater(format!("http: {e}")))?;
468    let status = resp.status();
469    if !status.is_success() {
470        return Err(Error::Updater(format!("github returned status {status}")));
471    }
472
473    let mut file = tokio::fs::File::create(dst)
474        .await
475        .map_err(|e| Error::Updater(format!("download: {e}")))?;
476    let mut stream = resp.bytes_stream();
477    while let Some(chunk) = stream.next().await {
478        let chunk = chunk.map_err(|e| Error::Updater(format!("http: {e}")))?;
479        file.write_all(&chunk)
480            .await
481            .map_err(|e| Error::Updater(format!("download: {e}")))?;
482    }
483    file.flush()
484        .await
485        .map_err(|e| Error::Updater(format!("download: {e}")))?;
486    Ok(())
487}
488
489/// Swap the staged binary into place.
490///
491/// **Unix**: `rename(new, current)` works because a running process
492/// holds its binary by inode.
493///
494/// **Windows**: the running exe's path is locked for writes, but
495/// renaming the file to a different name is allowed. Move current aside,
496/// drop the new file into place. For fresh targets (the binary didn't
497/// exist — e.g. just wiped), skip the rename-aside step.
498#[cfg(unix)]
499fn self_replace(current: &Path, new: &Path) -> Result<(), Error> {
500    std::fs::rename(new, current).map_err(|e| Error::Updater(format!("swap: {e}")))
501}
502
503#[cfg(windows)]
504fn self_replace(current: &Path, new: &Path) -> Result<(), Error> {
505    let old = current.with_extension("exe.old");
506    let _ = std::fs::remove_file(&old);
507    if current.exists() {
508        std::fs::rename(current, &old).map_err(|e| Error::Updater(format!("swap: {e}")))?;
509    }
510    std::fs::rename(new, current).map_err(|e| {
511        // Best effort: restore the original so the user isn't left
512        // with a missing binary on PATH.
513        let _ = std::fs::rename(&old, current);
514        Error::Updater(format!("swap: {e}"))
515    })
516}
517
518#[cfg(not(any(unix, windows)))]
519fn self_replace(_current: &Path, _new: &Path) -> Result<(), Error> {
520    Err(Error::Updater(
521        "self-replace not implemented on this platform".to_string(),
522    ))
523}
524
525fn sweep_stale_old(current: &Path) {
526    #[cfg(windows)]
527    {
528        let old = current.with_extension("exe.old");
529        let _ = std::fs::remove_file(old);
530    }
531    #[cfg(not(windows))]
532    {
533        let _ = current;
534    }
535}
536
537pub mod request_schema {
538    use objectiveai_sdk::cli::command::update as sdk;
539    use objectiveai_sdk::cli::command::update::request_schema::{Request, Response};
540
541    use crate::context::Context;
542    use crate::error::Error;
543
544    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
545        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
546    }
547}
548
549pub mod response_schema {
550    use objectiveai_sdk::cli::command::update as sdk;
551    use objectiveai_sdk::cli::command::update::response_schema::{Request, Response};
552
553    use crate::context::Context;
554    use crate::error::Error;
555
556    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
557        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Response)))
558    }
559}