Skip to main content

ctx/
update.rs

1//! Release and update mechanism: `ctx self-update`, `ctx --version --check`,
2//! and the passive update notice.
3//!
4//! # Determinism rule
5//!
6//! ctx **never updates itself automatically**. The passive check only prints
7//! a one-line notice to stderr; replacing the binary always requires an
8//! explicit `ctx self-update` invocation.
9//!
10//! # How updates work
11//!
12//! Releases are published by `.github/workflows/release.yml`: one archive per
13//! platform named `ctx-<tag>-<target>.tar.gz` (`.zip` on Windows), plus an
14//! aggregated `SHA256SUMS` file. `self_update` queries the GitHub Releases
15//! API, downloads the artifact for the compile-time target, verifies its
16//! sha256 against `SHA256SUMS`, extracts the `ctx` binary member, and
17//! atomically renames it over the running executable. On checksum mismatch
18//! the update aborts with an error (exit code 2) and the installed binary is
19//! left untouched.
20//!
21//! # Test hooks (undocumented environment variables)
22//!
23//! - `CTX_UPDATE_BASE_URL`: overrides the GitHub API base URL so integration
24//!   tests can point at a local mock server (asset download URLs come from
25//!   the release JSON itself, so the mock controls those too).
26//! - `CTX_CACHE_DIR`: overrides the passive-check cache directory
27//!   (default: `dirs::cache_dir()/ctx`).
28//! - `CTX_UPDATE_FORCE_TTY=1`: makes the passive check treat stderr as a
29//!   terminal, so the end-to-end notice path can be exercised from a test
30//!   where stderr is a pipe.
31
32use std::collections::BTreeMap;
33use std::fs;
34use std::io::IsTerminal;
35use std::io::Read;
36use std::path::{Path, PathBuf};
37#[cfg(unix)]
38use std::process::{Command, Stdio};
39use std::time::Duration;
40
41use semver::Version;
42use sha2::{Digest, Sha256};
43
44use crate::error::{CtxError, Result};
45
46/// GitHub API base for this repository's releases.
47const DEFAULT_BASE_URL: &str = "https://api.github.com/repos/agentis-tools/ctx";
48
49/// How often the passive check may touch the network (24 hours).
50pub const PASSIVE_CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
51
52/// Cache file (under the cache dir) storing the unix timestamp of the last
53/// passive check attempt.
54const STAMP_FILE: &str = "last-update-check";
55
56/// Network budget for the passive (notice-only) check.
57const PASSIVE_TIMEOUT: Duration = Duration::from_secs(1);
58
59/// Network budget for explicit operations (`self-update`, `--version --check`).
60const EXPLICIT_TIMEOUT: Duration = Duration::from_secs(30);
61
62// ============================================================================
63// Platform / artifact mapping
64// ============================================================================
65
66/// The release target triple this binary was compiled for, or `None` when
67/// the release workflow publishes no artifact for this platform.
68///
69/// This table mirrors the build matrix in `.github/workflows/release.yml`
70/// exactly; keep the two in sync.
71pub fn release_target() -> Option<&'static str> {
72    if cfg!(all(
73        target_os = "linux",
74        target_arch = "x86_64",
75        target_env = "gnu"
76    )) {
77        Some("x86_64-unknown-linux-gnu")
78    } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
79        Some("x86_64-apple-darwin")
80    } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
81        Some("aarch64-apple-darwin")
82    } else if cfg!(all(
83        target_os = "windows",
84        target_arch = "x86_64",
85        target_env = "msvc"
86    )) {
87        Some("x86_64-pc-windows-msvc")
88    } else {
89        None
90    }
91}
92
93/// Release artifact file name for a tag and target, mirroring the packaging
94/// steps in `.github/workflows/release.yml` (`ctx-<tag>-<target>.tar.gz`,
95/// `.zip` for Windows). `tag` includes the leading `v` (it is
96/// `$GITHUB_REF_NAME` in the workflow).
97pub fn artifact_name(tag: &str, target: &str) -> String {
98    let ext = if target.contains("windows") {
99        "zip"
100    } else {
101        "tar.gz"
102    };
103    format!("ctx-{tag}-{target}.{ext}")
104}
105
106/// The version this binary was built as.
107pub fn current_version() -> Version {
108    Version::parse(env!("CARGO_PKG_VERSION")).expect("CARGO_PKG_VERSION is valid semver")
109}
110
111// ============================================================================
112// GitHub Releases API client
113// ============================================================================
114
115/// One downloadable asset attached to a release.
116#[derive(Debug, Clone)]
117pub struct Asset {
118    pub name: String,
119    pub download_url: String,
120}
121
122/// A GitHub release: tag (e.g. `v0.3.0`) plus its assets.
123#[derive(Debug, Clone)]
124pub struct Release {
125    pub tag: String,
126    pub assets: Vec<Asset>,
127}
128
129impl Release {
130    /// The semver version encoded in the tag (`v0.3.0` -> `0.3.0`).
131    pub fn version(&self) -> Result<Version> {
132        Version::parse(self.tag.trim_start_matches('v'))
133            .map_err(|e| CtxError::Other(format!("release tag '{}' is not semver: {e}", self.tag)))
134    }
135
136    /// Find an asset by exact file name.
137    pub fn asset(&self, name: &str) -> Option<&Asset> {
138        self.assets.iter().find(|a| a.name == name)
139    }
140}
141
142/// API base URL, overridable via `CTX_UPDATE_BASE_URL` (test hook).
143fn base_url() -> String {
144    std::env::var("CTX_UPDATE_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
145}
146
147fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
148    reqwest::blocking::Client::builder()
149        .timeout(timeout)
150        .user_agent(concat!("ctx/", env!("CARGO_PKG_VERSION")))
151        .build()
152        .map_err(CtxError::Network)
153}
154
155/// Fetch the latest (non-prerelease, non-draft) release.
156pub fn latest_release(client: &reqwest::blocking::Client) -> Result<Release> {
157    fetch_release(client, &format!("{}/releases/latest", base_url()))
158}
159
160/// Fetch a specific release by tag (e.g. `v0.3.0`).
161pub fn release_by_tag(client: &reqwest::blocking::Client, tag: &str) -> Result<Release> {
162    fetch_release(client, &format!("{}/releases/tags/{tag}", base_url()))
163}
164
165fn fetch_release(client: &reqwest::blocking::Client, url: &str) -> Result<Release> {
166    let response = client
167        .get(url)
168        .header("Accept", "application/vnd.github+json")
169        .send()?;
170    let status = response.status();
171    if !status.is_success() {
172        return Err(CtxError::Other(format!(
173            "release query failed: {url} returned HTTP {status}"
174        )));
175    }
176    let value: serde_json::Value = response.json()?;
177    parse_release(&value)
178}
179
180/// Parse the GitHub Releases API JSON for one release.
181pub fn parse_release(value: &serde_json::Value) -> Result<Release> {
182    let tag = value["tag_name"]
183        .as_str()
184        .ok_or_else(|| CtxError::Other("release JSON has no tag_name".to_string()))?
185        .to_string();
186    let assets = value["assets"]
187        .as_array()
188        .map(|assets| {
189            assets
190                .iter()
191                .filter_map(|a| {
192                    Some(Asset {
193                        name: a["name"].as_str()?.to_string(),
194                        download_url: a["browser_download_url"].as_str()?.to_string(),
195                    })
196                })
197                .collect()
198        })
199        .unwrap_or_default();
200    Ok(Release { tag, assets })
201}
202
203// ============================================================================
204// SHA256SUMS
205// ============================================================================
206
207/// Parse a `SHA256SUMS` file: one `<hex>  <name>` entry per line (the
208/// `sha256sum` / `shasum -a 256` format; a leading `*` on the name marks
209/// binary mode and is stripped). Returns file name -> lowercase hex digest.
210pub fn parse_sha256sums(text: &str) -> BTreeMap<String, String> {
211    let mut sums = BTreeMap::new();
212    for line in text.lines() {
213        let line = line.trim();
214        if line.is_empty() {
215            continue;
216        }
217        let Some((hex, name)) = line.split_once(char::is_whitespace) else {
218            continue;
219        };
220        let name = name.trim_start().trim_start_matches('*');
221        if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) && !name.is_empty() {
222            sums.insert(name.to_string(), hex.to_ascii_lowercase());
223        }
224    }
225    sums
226}
227
228fn sha256_hex(data: &[u8]) -> String {
229    let digest = Sha256::digest(data);
230    digest.iter().map(|b| format!("{b:02x}")).collect()
231}
232
233// ============================================================================
234// Archive extraction
235// ============================================================================
236
237/// Extract the ctx binary member from a downloaded release archive.
238///
239/// Unix artifacts are `.tar.gz` containing `ctx`; Windows artifacts are
240/// `.zip` containing `ctx.exe`. Extraction by basename also preserves
241/// compatibility with older archives that nested files in a directory.
242fn extract_binary(archive: &[u8], artifact: &str) -> Result<Vec<u8>> {
243    if artifact.ends_with(".tar.gz") {
244        return extract_from_tar_gz(archive);
245    }
246    #[cfg(windows)]
247    if artifact.ends_with(".zip") {
248        return extract_from_zip(archive);
249    }
250    Err(CtxError::Other(format!(
251        "cannot extract '{artifact}': unsupported archive format for this platform"
252    )))
253}
254
255fn extract_from_tar_gz(archive: &[u8]) -> Result<Vec<u8>> {
256    let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
257    for entry in tar.entries()? {
258        let mut entry = entry?;
259        if !entry.header().entry_type().is_file() {
260            continue;
261        }
262        let is_ctx = entry.path()?.file_name().is_some_and(|name| name == "ctx");
263        if is_ctx {
264            let mut binary = Vec::new();
265            entry.read_to_end(&mut binary)?;
266            return Ok(binary);
267        }
268    }
269    Err(CtxError::Other(
270        "release archive contains no 'ctx' binary member".to_string(),
271    ))
272}
273
274#[cfg(windows)]
275fn extract_from_zip(archive: &[u8]) -> Result<Vec<u8>> {
276    let cursor = std::io::Cursor::new(archive);
277    let mut zip = zip::ZipArchive::new(cursor)
278        .map_err(|e| CtxError::Other(format!("invalid release zip: {e}")))?;
279    for i in 0..zip.len() {
280        let mut entry = zip
281            .by_index(i)
282            .map_err(|e| CtxError::Other(format!("invalid release zip entry: {e}")))?;
283        if entry.is_dir() {
284            continue;
285        }
286        let is_ctx = entry
287            .enclosed_name()
288            .and_then(|p| p.file_name().map(|n| n.to_os_string()))
289            .is_some_and(|name| name == "ctx.exe");
290        if is_ctx {
291            let mut binary = Vec::new();
292            entry.read_to_end(&mut binary)?;
293            return Ok(binary);
294        }
295    }
296    Err(CtxError::Other(
297        "release archive contains no 'ctx.exe' binary member".to_string(),
298    ))
299}
300
301// ============================================================================
302// Atomic executable replacement
303// ============================================================================
304
305/// Check that we can create files in the executable's directory before doing
306/// any network work.
307fn ensure_writable(dir: &Path) -> Result<()> {
308    let probe = dir.join(format!(".ctx-write-probe-{}", std::process::id()));
309    let outcome = fs::write(&probe, b"").and_then(|()| fs::remove_file(&probe));
310    outcome.map_err(|e| {
311        CtxError::Other(format!(
312            "cannot update: install location '{}' is not writable ({e}); \
313             re-run with elevated permissions or update through the package \
314             manager that installed ctx (e.g. 'cargo install agentis-ctx')",
315            dir.display()
316        ))
317    })
318}
319
320/// Atomically replace `exe` with `data`: write to a temp file in the same
321/// directory, set executable permissions, then rename over the target.
322///
323/// On Windows the running executable cannot be overwritten, but it can be
324/// renamed: the current `ctx.exe` is moved aside to `ctx.exe.old` first, and
325/// the new binary is renamed into place. The `.old` file is removed on the
326/// next `ctx self-update` run (or can be deleted manually).
327fn replace_executable(exe: &Path, data: &[u8]) -> Result<()> {
328    let dir = exe
329        .parent()
330        .ok_or_else(|| CtxError::Other("executable has no parent directory".to_string()))?;
331    let staged = dir.join(format!(".ctx-update-{}", std::process::id()));
332    if let Err(e) = fs::write(&staged, data) {
333        let _ = fs::remove_file(&staged);
334        return Err(e.into());
335    }
336
337    #[cfg(unix)]
338    {
339        use std::os::unix::fs::PermissionsExt;
340        if let Err(e) = fs::set_permissions(&staged, fs::Permissions::from_mode(0o755)) {
341            let _ = fs::remove_file(&staged);
342            return Err(e.into());
343        }
344        if let Err(e) = fs::rename(&staged, exe) {
345            let _ = fs::remove_file(&staged);
346            return Err(e.into());
347        }
348    }
349
350    #[cfg(windows)]
351    {
352        let old = old_binary_path(exe);
353        let _ = fs::remove_file(&old);
354        if let Err(e) = fs::rename(exe, &old) {
355            let _ = fs::remove_file(&staged);
356            return Err(e.into());
357        }
358        if let Err(e) = fs::rename(&staged, exe) {
359            // Roll the original back into place before failing.
360            let _ = fs::rename(&old, exe);
361            let _ = fs::remove_file(&staged);
362            return Err(e.into());
363        }
364    }
365
366    Ok(())
367}
368
369#[cfg(windows)]
370fn old_binary_path(exe: &Path) -> PathBuf {
371    let mut name = exe
372        .file_name()
373        .map(|n| n.to_os_string())
374        .unwrap_or_default();
375    name.push(".old");
376    exe.with_file_name(name)
377}
378
379// ============================================================================
380// ctx self-update
381// ============================================================================
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384enum ManagedInstall {
385    Cargo,
386    Homebrew,
387    Arch,
388    Debian,
389    Rpm,
390    Scoop,
391}
392
393impl ManagedInstall {
394    fn update_guidance(self) -> &'static str {
395        match self {
396            Self::Cargo => "ctx was installed with Cargo. Update it with:\n  cargo install agentis-ctx",
397            Self::Homebrew => "ctx was installed with Homebrew. Update it with:\n  brew upgrade ctx",
398            Self::Arch => "ctx is owned by an Arch package. Update it with your AUR helper, for example:\n  yay -Syu ctx-bin",
399            Self::Debian => "ctx is owned by a Debian package. Update it by installing a newer .deb package",
400            Self::Rpm => "ctx is owned by an RPM package. Update it by installing a newer .rpm package",
401            Self::Scoop => "ctx was installed with Scoop. Update it with:\n  scoop update ctx",
402        }
403    }
404}
405
406fn path_managed_install(exe: &Path) -> Option<ManagedInstall> {
407    let normalized = exe
408        .to_string_lossy()
409        .replace('\\', "/")
410        .to_ascii_lowercase();
411    let components: Vec<_> = normalized
412        .split('/')
413        .filter(|part| !part.is_empty())
414        .collect();
415
416    if components.windows(2).any(|pair| pair == [".cargo", "bin"]) {
417        return Some(ManagedInstall::Cargo);
418    }
419    if components.contains(&"cellar") {
420        return Some(ManagedInstall::Homebrew);
421    }
422    if components
423        .windows(3)
424        .any(|parts| parts == ["scoop", "apps", "ctx"])
425    {
426        return Some(ManagedInstall::Scoop);
427    }
428    None
429}
430
431#[cfg(unix)]
432fn command_owns_path(program: &str, args: &[&str], exe: &Path) -> bool {
433    Command::new(program)
434        .args(args)
435        .arg(exe)
436        .stdin(Stdio::null())
437        .stdout(Stdio::null())
438        .stderr(Stdio::null())
439        .status()
440        .is_ok_and(|status| status.success())
441}
442
443/// Identify installations owned by a package manager without relying only on
444/// writability. Package database queries are local and read-only. A failure or
445/// missing command means "not detected", so direct release installs continue
446/// to support self-update.
447fn managed_install(exe: &Path) -> Option<ManagedInstall> {
448    let canonical = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf());
449    if let Some(manager) = path_managed_install(&canonical) {
450        return Some(manager);
451    }
452
453    #[cfg(unix)]
454    {
455        if command_owns_path("pacman", &["-Qo", "--"], &canonical) {
456            return Some(ManagedInstall::Arch);
457        }
458        if command_owns_path("dpkg-query", &["-S", "--"], &canonical) {
459            return Some(ManagedInstall::Debian);
460        }
461        if command_owns_path("rpm", &["-qf", "--"], &canonical) {
462            return Some(ManagedInstall::Rpm);
463        }
464    }
465    None
466}
467
468/// Result of a [`self_update`] run.
469#[derive(Debug, Clone)]
470pub struct SelfUpdateReport {
471    pub old_version: Version,
472    pub new_version: Version,
473    /// `false` when there was nothing to do (already up to date).
474    pub updated: bool,
475}
476
477/// Update the running executable from the GitHub release feed.
478///
479/// `pin` installs an exact version (`X.Y.Z`, with or without a leading `v`)
480/// instead of the latest release; pinning also allows downgrades. Returns
481/// with `updated: false` when there is nothing to do. All failure modes
482/// (network, missing artifact, checksum mismatch, unwritable install
483/// location) return `Err`, which the CLI maps to exit code 2, and always
484/// leave the installed binary untouched.
485pub fn self_update(pin: Option<&str>) -> Result<SelfUpdateReport> {
486    let exe = std::env::current_exe()?;
487    let dir = exe
488        .parent()
489        .ok_or_else(|| CtxError::Other("cannot locate the running executable".to_string()))?;
490
491    // Refuse before network access or any filesystem mutation. A package
492    // manager's database must remain authoritative for files it owns.
493    if let Some(manager) = managed_install(&exe) {
494        return Err(CtxError::Other(manager.update_guidance().to_string()));
495    }
496
497    // Clean up the renamed-aside binary a previous Windows update left behind.
498    #[cfg(windows)]
499    {
500        let _ = fs::remove_file(old_binary_path(&exe));
501    }
502
503    // Refuse before any network work when we could not replace the binary.
504    ensure_writable(dir)?;
505
506    let client = http_client(EXPLICIT_TIMEOUT)?;
507    let release = match pin {
508        Some(version) => {
509            let tag = format!("v{}", version.trim_start_matches('v'));
510            release_by_tag(&client, &tag)?
511        }
512        None => latest_release(&client)?,
513    };
514    let new_version = release.version()?;
515    let old_version = current_version();
516
517    // Latest: only move forward. Pinned: install anything that differs
518    // (explicit downgrades are allowed).
519    let update_needed = match pin {
520        Some(_) => new_version != old_version,
521        None => new_version > old_version,
522    };
523    if !update_needed {
524        return Ok(SelfUpdateReport {
525            old_version,
526            new_version,
527            updated: false,
528        });
529    }
530
531    let target = release_target().ok_or_else(|| {
532        CtxError::Other(format!(
533            "no prebuilt release artifact for this platform ({}-{}); \
534             update with 'cargo install agentis-ctx' instead",
535            std::env::consts::ARCH,
536            std::env::consts::OS
537        ))
538    })?;
539    let artifact = artifact_name(&release.tag, target);
540    let asset = release.asset(&artifact).ok_or_else(|| {
541        CtxError::Other(format!(
542            "release {} has no artifact named '{artifact}'",
543            release.tag
544        ))
545    })?;
546    let sums_asset = release.asset("SHA256SUMS").ok_or_else(|| {
547        CtxError::Other(format!(
548            "release {} publishes no SHA256SUMS file; refusing to install an unverifiable binary",
549            release.tag
550        ))
551    })?;
552
553    let sums_text = download_text(&client, &sums_asset.download_url)?;
554    let sums = parse_sha256sums(&sums_text);
555    let expected = sums.get(&artifact).ok_or_else(|| {
556        CtxError::Other(format!(
557            "SHA256SUMS for release {} has no entry for '{artifact}'",
558            release.tag
559        ))
560    })?;
561
562    let archive = download_bytes(&client, &asset.download_url)?;
563    let actual = sha256_hex(&archive);
564    if actual != *expected {
565        return Err(CtxError::Other(format!(
566            "checksum mismatch for '{artifact}': expected {expected}, got {actual}; \
567             aborting update (the installed binary is unchanged)"
568        )));
569    }
570
571    let binary = extract_binary(&archive, &artifact)?;
572    replace_executable(&exe, &binary)?;
573
574    Ok(SelfUpdateReport {
575        old_version,
576        new_version,
577        updated: true,
578    })
579}
580
581fn download_text(client: &reqwest::blocking::Client, url: &str) -> Result<String> {
582    let response = client.get(url).send()?;
583    let status = response.status();
584    if !status.is_success() {
585        return Err(CtxError::Other(format!(
586            "download failed: {url} returned HTTP {status}"
587        )));
588    }
589    Ok(response.text()?)
590}
591
592fn download_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
593    let response = client.get(url).send()?;
594    let status = response.status();
595    if !status.is_success() {
596        return Err(CtxError::Other(format!(
597            "download failed: {url} returned HTTP {status}"
598        )));
599    }
600    Ok(response.bytes()?.to_vec())
601}
602
603// ============================================================================
604// Explicit version check (ctx --version --check)
605// ============================================================================
606
607/// The stderr notice printed by the passive check, also reused by
608/// `ctx --version --check`.
609pub fn update_notice(latest: &Version, current: &Version) -> String {
610    format!("ctx {latest} available (you have {current}) — run 'ctx self-update'")
611}
612
613/// Query the latest release and compare against the running binary.
614///
615/// Used by `ctx --version --check`; always allowed (no suppression, no 24h
616/// cache). Returns `(current, latest)`.
617pub fn explicit_check() -> Result<(Version, Version)> {
618    let client = http_client(EXPLICIT_TIMEOUT)?;
619    let latest = latest_release(&client)?.version()?;
620    Ok((current_version(), latest))
621}
622
623// ============================================================================
624// Passive update check
625// ============================================================================
626
627/// Cache directory for the passive-check timestamp:
628/// `$CTX_CACHE_DIR` (test hook) or `dirs::cache_dir()/ctx`.
629pub fn cache_dir() -> Option<PathBuf> {
630    if let Ok(dir) = std::env::var("CTX_CACHE_DIR") {
631        if !dir.is_empty() {
632            return Some(PathBuf::from(dir));
633        }
634    }
635    dirs::cache_dir().map(|d| d.join("ctx"))
636}
637
638/// True when the 24h interval since the last recorded check has elapsed
639/// (or no valid timestamp is recorded). `now` is unix seconds, injected so
640/// tests control the clock.
641pub fn check_is_due(stamp_file: &Path, now: u64) -> bool {
642    let last = fs::read_to_string(stamp_file)
643        .ok()
644        .and_then(|s| s.trim().parse::<u64>().ok());
645    match last {
646        Some(last) => now >= last.saturating_add(PASSIVE_CHECK_INTERVAL_SECS),
647        None => true,
648    }
649}
650
651/// Record `now` (unix seconds) as the last check attempt.
652pub fn record_check(stamp_file: &Path, now: u64) -> std::io::Result<()> {
653    if let Some(parent) = stamp_file.parent() {
654        fs::create_dir_all(parent)?;
655    }
656    fs::write(stamp_file, format!("{now}\n"))
657}
658
659/// The passive-check suppression predicate (H3). Pure so the whole gate
660/// matrix is unit-testable; `env` abstracts `std::env::var`.
661///
662/// The check is allowed only when **none** of these hold:
663/// - `--json` is active (stdout is a machine-readable document),
664/// - stderr is not a terminal (covers CI, pipes, and test harnesses),
665/// - `CTX_NO_UPDATE_CHECK` is set (non-empty),
666/// - a Claude Code hook/session environment is detected (`CLAUDECODE`,
667///   `CLAUDE_PROJECT_DIR`, or `CLAUDE_PLUGIN_ROOT`).
668///
669/// Suppression means **no network call at all**, not just a swallowed notice.
670pub fn passive_check_allowed<F>(json: bool, stderr_is_tty: bool, env: F) -> bool
671where
672    F: Fn(&str) -> Option<String>,
673{
674    if json || !stderr_is_tty {
675        return false;
676    }
677    if env("CTX_NO_UPDATE_CHECK").is_some_and(|v| !v.is_empty()) {
678        return false;
679    }
680    // Inside a Claude Code hook or session: never interfere.
681    for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
682        if env(var).is_some() {
683            return false;
684        }
685    }
686    true
687}
688
689/// Passive update check, called from `main` after the command has finished
690/// (`ctx self-update` and `ctx --version` invocations skip it entirely).
691///
692/// At most one network request per 24h (timestamp cache), 1-second timeout,
693/// silent on any failure, never panics, never writes to stdout. When a newer
694/// release exists it prints exactly one line to stderr:
695///
696/// ```text
697/// ctx <new> available (you have <current>) — run 'ctx self-update'
698/// ```
699pub fn passive_check(json: bool) {
700    // CTX_UPDATE_FORCE_TTY=1 is a test-only hook: a test cannot give the
701    // child process a real stderr TTY, so it injects the TTY gate instead.
702    let stderr_is_tty = std::env::var("CTX_UPDATE_FORCE_TTY").map(|v| v == "1") == Ok(true)
703        || std::io::stderr().is_terminal();
704    if !passive_check_allowed(json, stderr_is_tty, |k| std::env::var(k).ok()) {
705        return;
706    }
707    // Failures are silent by design: an update notice is never worth
708    // breaking a working command for.
709    let _ = passive_check_inner();
710}
711
712fn passive_check_inner() -> Result<()> {
713    let dir = cache_dir().ok_or_else(|| CtxError::Other("no cache dir".to_string()))?;
714    let stamp = dir.join(STAMP_FILE);
715    let now = std::time::SystemTime::now()
716        .duration_since(std::time::UNIX_EPOCH)
717        .map(|d| d.as_secs())
718        .unwrap_or(0);
719    if !check_is_due(&stamp, now) {
720        return Ok(());
721    }
722    // Record the attempt before the network call so failures do not turn
723    // into a retry on every invocation.
724    record_check(&stamp, now)?;
725
726    let client = http_client(PASSIVE_TIMEOUT)?;
727    let latest = latest_release(&client)?.version()?;
728    let current = current_version();
729    if latest > current {
730        eprintln!("{}", update_notice(&latest, &current));
731    }
732    Ok(())
733}
734
735// ============================================================================
736// Tests
737// ============================================================================
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    #[test]
744    fn test_managed_install_path_detection() {
745        assert_eq!(
746            path_managed_install(Path::new("/Users/alice/.cargo/bin/ctx")),
747            Some(ManagedInstall::Cargo)
748        );
749        assert_eq!(
750            path_managed_install(Path::new("/opt/homebrew/Cellar/ctx/0.3.4/bin/ctx")),
751            Some(ManagedInstall::Homebrew)
752        );
753        assert_eq!(
754            path_managed_install(Path::new(r"C:\Users\Alice\scoop\apps\ctx\current\ctx.exe")),
755            Some(ManagedInstall::Scoop)
756        );
757        assert_eq!(path_managed_install(Path::new("/usr/local/bin/ctx")), None);
758    }
759
760    #[test]
761    fn test_managed_install_guidance_names_update_command() {
762        assert!(ManagedInstall::Cargo
763            .update_guidance()
764            .contains("cargo install agentis-ctx"));
765        assert!(ManagedInstall::Homebrew
766            .update_guidance()
767            .contains("brew upgrade ctx"));
768        assert!(ManagedInstall::Arch
769            .update_guidance()
770            .contains("yay -Syu ctx-bin"));
771        assert!(ManagedInstall::Scoop
772            .update_guidance()
773            .contains("scoop update ctx"));
774    }
775
776    // ---- platform / artifact mapping -------------------------------------
777
778    #[test]
779    fn test_artifact_names_mirror_release_workflow() {
780        // One entry per release.yml build-matrix row; tag == $GITHUB_REF_NAME.
781        let expected = [
782            (
783                "x86_64-unknown-linux-gnu",
784                "ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz",
785            ),
786            (
787                "x86_64-apple-darwin",
788                "ctx-v0.3.0-x86_64-apple-darwin.tar.gz",
789            ),
790            (
791                "aarch64-apple-darwin",
792                "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
793            ),
794            (
795                "x86_64-pc-windows-msvc",
796                "ctx-v0.3.0-x86_64-pc-windows-msvc.zip",
797            ),
798        ];
799        for (target, name) in expected {
800            assert_eq!(artifact_name("v0.3.0", target), name);
801        }
802    }
803
804    #[test]
805    fn test_release_target_matches_compile_target() {
806        let expected = if cfg!(all(
807            target_os = "linux",
808            target_arch = "x86_64",
809            target_env = "gnu"
810        )) {
811            Some("x86_64-unknown-linux-gnu")
812        } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
813            Some("x86_64-apple-darwin")
814        } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
815            Some("aarch64-apple-darwin")
816        } else if cfg!(all(
817            target_os = "windows",
818            target_arch = "x86_64",
819            target_env = "msvc"
820        )) {
821            Some("x86_64-pc-windows-msvc")
822        } else {
823            None
824        };
825        assert_eq!(release_target(), expected);
826        // Every supported target maps to a well-formed artifact name.
827        if let Some(target) = release_target() {
828            let name = artifact_name("v1.0.0", target);
829            assert!(name.starts_with("ctx-v1.0.0-"));
830            assert!(name.ends_with(".tar.gz") || name.ends_with(".zip"));
831        }
832    }
833
834    // ---- SHA256SUMS -------------------------------------------------------
835
836    #[test]
837    fn test_parse_sha256sums_multi_line() {
838        let hex_a = "a".repeat(64);
839        let hex_b = "B".repeat(64);
840        let text = format!(
841            "{hex_a}  ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz\n\
842             \n\
843             {hex_b} *ctx-v0.3.0-x86_64-pc-windows-msvc.zip\n\
844             not-a-sum-line\n\
845             deadbeef  too-short-digest.tar.gz\n"
846        );
847        let sums = parse_sha256sums(&text);
848        assert_eq!(sums.len(), 2);
849        assert_eq!(
850            sums["ctx-v0.3.0-x86_64-unknown-linux-gnu.tar.gz"], hex_a,
851            "plain entry parses"
852        );
853        // Binary-mode marker stripped, digest lowercased.
854        assert_eq!(
855            sums["ctx-v0.3.0-x86_64-pc-windows-msvc.zip"],
856            hex_b.to_ascii_lowercase()
857        );
858    }
859
860    #[test]
861    fn test_sha256_hex_matches_known_vector() {
862        // sha256("abc")
863        assert_eq!(
864            sha256_hex(b"abc"),
865            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
866        );
867    }
868
869    // ---- release JSON parsing ---------------------------------------------
870
871    #[test]
872    fn test_parse_release_and_version_comparison() {
873        let value = serde_json::json!({
874            "tag_name": "v0.3.0",
875            "assets": [
876                {"name": "SHA256SUMS", "browser_download_url": "https://x/SHA256SUMS"},
877                {"name": "ctx-v0.3.0-aarch64-apple-darwin.tar.gz",
878                 "browser_download_url": "https://x/ctx.tar.gz"},
879                {"malformed": true}
880            ]
881        });
882        let release = parse_release(&value).unwrap();
883        assert_eq!(release.tag, "v0.3.0");
884        assert_eq!(release.assets.len(), 2, "malformed asset entries skipped");
885        assert_eq!(release.version().unwrap(), Version::new(0, 3, 0));
886        assert!(release.asset("SHA256SUMS").is_some());
887        assert!(release.asset("nope.tar.gz").is_none());
888
889        // Version comparison semantics used by self_update / the notices.
890        assert!(Version::new(1, 0, 0) > Version::new(0, 9, 9));
891        assert!(Version::parse("0.3.0-rc.1").unwrap() < Version::new(0, 3, 0));
892    }
893
894    // ---- 24h cache --------------------------------------------------------
895
896    #[test]
897    fn test_check_is_due_with_injected_clock() {
898        let temp = tempfile::tempdir().unwrap();
899        let stamp = temp.path().join("nested").join("last-update-check");
900
901        // No stamp file yet: due.
902        assert!(check_is_due(&stamp, 1_000_000));
903
904        record_check(&stamp, 1_000_000).unwrap();
905        // Within 24h: not due (boundary excluded).
906        assert!(!check_is_due(&stamp, 1_000_000));
907        assert!(!check_is_due(
908            &stamp,
909            1_000_000 + PASSIVE_CHECK_INTERVAL_SECS - 1
910        ));
911        // At/after 24h: due again.
912        assert!(check_is_due(
913            &stamp,
914            1_000_000 + PASSIVE_CHECK_INTERVAL_SECS
915        ));
916
917        // Corrupt stamp: due.
918        fs::write(&stamp, "not-a-number").unwrap();
919        assert!(check_is_due(&stamp, 1_000_000));
920    }
921
922    // ---- suppression matrix -------------------------------------------------
923
924    #[test]
925    fn test_passive_check_suppression_matrix() {
926        let no_env = |_: &str| -> Option<String> { None };
927        let env_with = |key: &'static str| move |k: &str| (k == key).then(|| "1".to_string());
928
929        // Clean interactive invocation: allowed.
930        assert!(passive_check_allowed(false, true, no_env));
931
932        // --json active: suppressed.
933        assert!(!passive_check_allowed(true, true, no_env));
934        // stderr not a TTY: suppressed (covers CI and tests).
935        assert!(!passive_check_allowed(false, false, no_env));
936        // Opt-out env var: suppressed.
937        assert!(!passive_check_allowed(
938            false,
939            true,
940            env_with("CTX_NO_UPDATE_CHECK")
941        ));
942        // ...but an empty value does not suppress.
943        assert!(passive_check_allowed(false, true, |k: &str| (k
944            == "CTX_NO_UPDATE_CHECK")
945            .then(String::new)));
946        // Claude Code hook environment: suppressed, any of the three vars.
947        for var in ["CLAUDECODE", "CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_ROOT"] {
948            assert!(
949                !passive_check_allowed(false, true, env_with(var)),
950                "{var} must suppress the passive check"
951            );
952        }
953        // Combinations still suppress.
954        assert!(!passive_check_allowed(true, false, env_with("CLAUDECODE")));
955    }
956
957    // ---- notice text --------------------------------------------------------
958
959    #[test]
960    fn test_update_notice_format() {
961        let notice = update_notice(&Version::new(9, 9, 9), &Version::new(0, 2, 1));
962        assert_eq!(
963            notice,
964            "ctx 9.9.9 available (you have 0.2.1) — run 'ctx self-update'"
965        );
966    }
967
968    // ---- archive extraction --------------------------------------------------
969
970    #[test]
971    fn test_extract_binary_from_tar_gz() {
972        let payload = b"#!/bin/sh\necho fake ctx\n".to_vec();
973        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
974        let mut builder = tar::Builder::new(gz);
975        let mut header = tar::Header::new_gnu();
976        header.set_size(payload.len() as u64);
977        header.set_mode(0o755);
978        header.set_cksum();
979        builder
980            .append_data(
981                &mut header,
982                "ctx-v9.9.9-aarch64-apple-darwin/ctx",
983                payload.as_slice(),
984            )
985            .unwrap();
986        let archive = builder.into_inner().unwrap().finish().unwrap();
987
988        let extracted = extract_binary(&archive, "ctx-v9.9.9-aarch64-apple-darwin.tar.gz").unwrap();
989        assert_eq!(extracted, payload);
990    }
991
992    #[test]
993    fn test_extract_binary_missing_member_errors() {
994        let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
995        let mut builder = tar::Builder::new(gz);
996        let mut header = tar::Header::new_gnu();
997        header.set_size(5);
998        header.set_mode(0o644);
999        header.set_cksum();
1000        builder
1001            .append_data(&mut header, "dir/README.md", &b"hello"[..])
1002            .unwrap();
1003        let archive = builder.into_inner().unwrap().finish().unwrap();
1004
1005        let err = extract_binary(&archive, "ctx-v9.9.9-x.tar.gz").unwrap_err();
1006        assert!(err.to_string().contains("no 'ctx' binary member"));
1007    }
1008
1009    // ---- atomic replacement ---------------------------------------------------
1010
1011    #[cfg(unix)]
1012    #[test]
1013    fn test_replace_executable_atomically_swaps_content() {
1014        use std::os::unix::fs::PermissionsExt;
1015
1016        let temp = tempfile::tempdir().unwrap();
1017        let exe = temp.path().join("ctx");
1018        fs::write(&exe, b"old binary").unwrap();
1019
1020        replace_executable(&exe, b"new binary").unwrap();
1021        assert_eq!(fs::read(&exe).unwrap(), b"new binary");
1022        let mode = fs::metadata(&exe).unwrap().permissions().mode();
1023        assert_eq!(mode & 0o755, 0o755, "replacement is executable");
1024
1025        // No staging leftovers.
1026        let leftovers: Vec<_> = fs::read_dir(temp.path())
1027            .unwrap()
1028            .filter_map(|e| e.ok())
1029            .filter(|e| e.file_name() != "ctx")
1030            .collect();
1031        assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
1032    }
1033
1034    #[test]
1035    fn test_ensure_writable_accepts_tempdir() {
1036        let temp = tempfile::tempdir().unwrap();
1037        ensure_writable(temp.path()).unwrap();
1038    }
1039
1040    #[cfg(unix)]
1041    #[test]
1042    fn test_ensure_writable_rejects_readonly_dir() {
1043        use std::os::unix::fs::PermissionsExt;
1044
1045        let temp = tempfile::tempdir().unwrap();
1046        let dir = temp.path().join("ro");
1047        fs::create_dir(&dir).unwrap();
1048        fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
1049
1050        let err = ensure_writable(&dir).unwrap_err();
1051        assert!(err.to_string().contains("not writable"), "{err}");
1052
1053        // Restore so the tempdir can be cleaned up.
1054        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
1055    }
1056}