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