Skip to main content

lean_ctx/core/
updater.rs

1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3
4#[cfg(test)]
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8#[path = "updater/flow.rs"]
9mod flow;
10#[path = "updater/github_api.rs"]
11mod github_api;
12#[path = "updater/rewire.rs"]
13mod rewire;
14#[path = "updater/transaction.rs"]
15mod transaction;
16
17use transaction::{
18    acquire_update_lock, execute_prepared_transaction, prepare_update_transaction,
19    recover_pending_transaction, rollback_to_previous,
20};
21#[cfg(test)]
22use transaction::{
23    cleanup_orphaned_prepared_files, orphan_prepared_paths, rollback_installation,
24    write_update_receipt,
25};
26
27pub(crate) use github_api::github_api_json;
28#[cfg(test)]
29use github_api::{github_token, rate_limit_message};
30pub use rewire::SetupRefresh;
31pub use rewire::migrate_shadow_mode_default_public;
32use rewire::{post_update_rewire, print_rewire_plan};
33
34mod platform;
35#[cfg(any(windows, test))]
36#[path = "updater/windows_sidecar.rs"]
37pub mod windows_sidecar;
38use platform::{
39    gpu_build_already_installed, gpu_next_steps, gpu_platform_asset_name, platform_asset_name,
40};
41#[cfg(feature = "secure-update")]
42mod sigstore;
43
44const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/yvgude/lean-ctx/releases/latest";
45const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
46const UPDATE_RECEIPT_SCHEMA: &str = "leanctx.update-receipt/v2";
47const LEGACY_UPDATE_RECEIPT_SCHEMA: &str = "leanctx.update-receipt/v1";
48const UPDATE_RECEIPT_FILE: &str = "update-receipt.json";
49const UPDATE_TRANSACTION_SCHEMA: &str = "leanctx.update-transaction/v1";
50const UPDATE_TRANSACTION_FILE: &str = "update-transaction.json";
51const UPDATE_LOCK_FILE: &str = "update.lock";
52
53/// `lean-ctx update`. `setup` is the refresh run after the binary is replaced.
54pub fn run(args: &[String], setup: &dyn SetupRefresh) {
55    run_with_mode(args, UpdateMode::Normal, setup);
56}
57
58/// `lean-ctx enable-gpu`. `setup` is the refresh run after the binary is replaced.
59pub fn enable_gpu(args: &[String], setup: &dyn SetupRefresh) {
60    run_with_mode(args, UpdateMode::EnableGpu, setup);
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum UpdateMode {
65    Normal,
66    EnableGpu,
67}
68
69/// Flags understood by the update flow. Reject typos before the flow can
70/// change the scheduler, config, or installed binary.
71const KNOWN_FLAGS: &[&str] = &[
72    "--check",
73    "--dry-run",
74    "--quiet",
75    "--skip-rules",
76    "--scheduled",
77    "--schedule",
78    "--insecure", // Preserve the flow's explicit refusal for this removed option.
79    "--status",
80    "--rollback",
81    "--unpin",
82    "--pin",
83    "--recover-update",
84    "--lock-held",
85    // Accepted for compatibility with older scripts and docs.
86    "--force",
87    "--rewire",
88];
89
90#[derive(Debug, PartialEq, Eq)]
91enum FlagCheck<'a> {
92    Run,
93    Help,
94    Unknown(&'a str),
95}
96
97fn check_flags(args: &[String]) -> FlagCheck<'_> {
98    if args.iter().any(|arg| arg == "--help" || arg == "-h") {
99        return FlagCheck::Help;
100    }
101
102    args.iter()
103        .map(String::as_str)
104        .find(|arg| arg.starts_with('-') && !KNOWN_FLAGS.contains(arg))
105        .map_or(FlagCheck::Run, FlagCheck::Unknown)
106}
107
108fn command_name(mode: UpdateMode) -> &'static str {
109    match mode {
110        UpdateMode::Normal => "update",
111        UpdateMode::EnableGpu => "enable-gpu",
112    }
113}
114
115fn print_help(mode: UpdateMode) {
116    let command = command_name(mode);
117    println!("Usage: lean-ctx {command} [VERSION] [OPTIONS]");
118    println!();
119    println!("  VERSION                install this release instead of the latest");
120    println!("  --check                only report whether an update is available");
121    println!("  --dry-run              show the release and the setup refresh; change nothing");
122    println!("  --quiet                suppress output unless something changes");
123    println!("  --skip-rules           do not refresh agent rules after updating");
124    println!("  The refresh touches only the tools chosen in `lean-ctx setup` (setup.agents),");
125    println!("  or, without a choice, tools already connected — never newly installed ones.");
126    if mode == UpdateMode::EnableGpu {
127        println!("  --force                reinstall the CUDA build of the current release");
128    }
129    println!("  --pin VERSION          pin and install this release");
130    println!("  --unpin                clear the release pin");
131    println!("  --status               show the pin and retained binary receipt");
132    println!("  --rollback             restore the retained verified binary");
133    println!("  --schedule [N|Nh]      install automatic updates every N hours (default 6)");
134    println!("  --schedule notify      only notify about updates");
135    println!("  --schedule status      show the update schedule");
136    println!("  --schedule off|disable disable automatic updates");
137    println!("  -h, --help             show this help");
138    println!();
139    println!("  The schedule (LaunchAgent, systemd timer, crontab, scheduled task) and the");
140    println!("  managed proxy restart belong to the default install; with LEAN_CTX_DATA_DIR,");
141    println!("  another directory override or another HOME they are left alone.");
142}
143
144fn run_with_mode(args: &[String], mode: UpdateMode, setup: &dyn SetupRefresh) {
145    match check_flags(args) {
146        FlagCheck::Run => {}
147        FlagCheck::Help => {
148            print_help(mode);
149            return;
150        }
151        FlagCheck::Unknown(flag) => {
152            eprintln!("  \x1b[31m✗\x1b[0m Unknown option: {flag}");
153            eprintln!(
154                "  \x1b[2mSee: lean-ctx {} --help\x1b[0m",
155                command_name(mode)
156            );
157            std::process::exit(2);
158        }
159    }
160
161    flow::run_with_mode(args, mode, setup);
162}
163
164#[cfg(test)]
165mod argument_guard_tests {
166    use super::{FlagCheck, UpdateMode, check_flags, command_name};
167
168    fn args(values: &[&str]) -> Vec<String> {
169        values.iter().map(ToString::to_string).collect()
170    }
171
172    #[test]
173    fn help_never_falls_through_to_an_update() {
174        assert_eq!(check_flags(&args(&["--help"])), FlagCheck::Help);
175        assert_eq!(check_flags(&args(&["-h"])), FlagCheck::Help);
176        assert_eq!(
177            check_flags(&args(&["--schedule", "off", "--broken", "--help"])),
178            FlagCheck::Help
179        );
180        assert_eq!(command_name(UpdateMode::Normal), "update");
181        assert_eq!(command_name(UpdateMode::EnableGpu), "enable-gpu");
182    }
183
184    #[test]
185    fn unknown_flags_are_refused() {
186        assert_eq!(
187            check_flags(&args(&["--chek"])),
188            FlagCheck::Unknown("--chek")
189        );
190        assert_eq!(
191            check_flags(&args(&["3.10.3", "--forse"])),
192            FlagCheck::Unknown("--forse")
193        );
194    }
195
196    #[test]
197    fn scheduler_and_known_invocations_still_run() {
198        for ok in [
199            &[][..],
200            &["--quiet", "--scheduled"][..],
201            &["--check"][..],
202            &["--dry-run"][..],
203            &["3.10.3", "--skip-rules"][..],
204            &["--schedule", "12h"][..],
205            &["--schedule", "off"][..],
206            &["--schedule", "disable"][..],
207            &["--status"][..],
208            &["--rollback"][..],
209            &["--pin", "3.10.3"][..],
210            &["--unpin"][..],
211            &["--recover-update", "--lock-held"][..],
212            &["--insecure"][..],
213            &["--force"][..],
214            &["--rewire"][..],
215        ] {
216            assert_eq!(check_flags(&args(ok)), FlagCheck::Run, "{ok:?}");
217        }
218    }
219}
220
221/// Outcome of the config gate applied to automatic (scheduled) update runs.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum AutoUpdateGate {
224    /// Install normally.
225    Proceed,
226    /// Auto-update disabled in config — skip and clean up the scheduler.
227    Skip,
228    /// Notify-only — check for a newer version but never install.
229    NotifyOnly,
230}
231
232/// Decide what a scheduled `update` run should do based on config. Pure helper
233/// so the precedence (`auto_update` wins over `notify_only`) is unit-testable.
234fn automatic_update_gate(auto_update: bool, notify_only: bool) -> AutoUpdateGate {
235    if !auto_update {
236        AutoUpdateGate::Skip
237    } else if notify_only {
238        AutoUpdateGate::NotifyOnly
239    } else {
240        AutoUpdateGate::Proceed
241    }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245struct UpdateReceipt {
246    schema_version: String,
247    active: BinaryReceipt,
248    previous: BinaryReceipt,
249    #[serde(default)]
250    receipt_sha256: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254struct BinaryReceipt {
255    version: String,
256    asset: String,
257    sha256: String,
258    size: u64,
259    path: String,
260    manifest_sha256: Option<String>,
261    archive_sha256: Option<String>,
262    #[serde(default)]
263    release_commit: Option<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267struct VerifiedArtifact {
268    archive_sha256: String,
269    manifest_sha256: String,
270    release_commit: String,
271    payload_sha256: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
275struct PreparedTransaction {
276    schema_version: String,
277    operation: String,
278    current_path: String,
279    previous_path: String,
280    staged_path: String,
281    backup_path: String,
282    old_active: BinaryReceipt,
283    target_active: BinaryReceipt,
284    target_previous: BinaryReceipt,
285    transaction_sha256: Option<String>,
286}
287
288#[derive(Debug, Deserialize)]
289#[cfg(feature = "secure-update")]
290struct ReleaseManifest {
291    schema_version: String,
292    tag: String,
293    commit: String,
294    artifacts: std::collections::HashMap<String, ManifestArtifact>,
295    sbom_sha256: String,
296    checksums_sha256: String,
297}
298
299#[derive(Debug, Deserialize)]
300#[cfg(feature = "secure-update")]
301struct ManifestArtifact {
302    sha256: String,
303    size: u64,
304    #[serde(default)]
305    payload_sha256: Option<String>,
306}
307
308fn verify_download_integrity(
309    release: &serde_json::Value,
310    asset_name: &str,
311    bytes: &[u8],
312) -> Result<VerifiedArtifact, String> {
313    #[cfg(not(feature = "secure-update"))]
314    {
315        let _ = (release, asset_name, bytes);
316        Err("secure-update feature disabled (sha256 verification unavailable)".to_string())
317    }
318
319    #[cfg(feature = "secure-update")]
320    {
321        let release_tag = release["tag_name"]
322            .as_str()
323            .ok_or_else(|| "release metadata has no tag_name".to_string())?;
324        let manifest_url = find_asset_url(release, "release-manifest.json")
325            .ok_or_else(|| "release-manifest.json is required for binary updates".to_string())?;
326        let manifest_bytes = download_bytes(&manifest_url)?;
327        let manifest_signature_url = find_asset_url(release, "release-manifest.json.sig")
328            .ok_or_else(|| {
329                "release-manifest.json.sig is required for binary updates".to_string()
330            })?;
331        let manifest_certificate_url = find_asset_url(release, "release-manifest.json.pem")
332            .ok_or_else(|| {
333                "release-manifest.json.pem is required for binary updates".to_string()
334            })?;
335        sigstore::verify_release_signature(
336            &manifest_bytes,
337            &download_bytes(&manifest_signature_url)?,
338            &download_bytes(&manifest_certificate_url)?,
339            release_tag,
340        )?;
341        let manifest_sha256 = sha256_hex(&manifest_bytes);
342        let manifest: ReleaseManifest = serde_json::from_slice(&manifest_bytes)
343            .map_err(|e| format!("invalid release-manifest.json: {e}"))?;
344        validate_manifest(&manifest, release_tag, asset_name)?;
345        let artifact = manifest
346            .artifacts
347            .get(asset_name)
348            .ok_or_else(|| format!("manifest has no artifact entry for {asset_name}"))?;
349        let computed = sha256_hex(bytes);
350        if bytes.len() as u64 != artifact.size {
351            return Err(format!(
352                "size mismatch for {asset_name}: expected {}, got {}",
353                artifact.size,
354                bytes.len()
355            ));
356        }
357        if !constant_time_eq(computed.as_bytes(), artifact.sha256.as_bytes()) {
358            return Err(format!(
359                "sha256 mismatch for {asset_name}: expected {}, got {computed}",
360                artifact.sha256
361            ));
362        }
363
364        let checksum_url = find_asset_url(release, "SHA256SUMS")
365            .ok_or_else(|| "SHA256SUMS is required for binary updates".to_string())?;
366        let checksum_bytes = download_bytes(&checksum_url)?;
367        let signature_url = find_asset_url(release, "SHA256SUMS.sig")
368            .ok_or_else(|| "SHA256SUMS.sig is required for binary updates".to_string())?;
369        let signature_bytes = download_bytes(&signature_url)?;
370        let certificate_url = find_asset_url(release, "SHA256SUMS.pem")
371            .ok_or_else(|| "SHA256SUMS.pem is required for binary updates".to_string())?;
372        let certificate_bytes = download_bytes(&certificate_url)?;
373        sigstore::verify_release_signature(
374            &checksum_bytes,
375            &signature_bytes,
376            &certificate_bytes,
377            release_tag,
378        )?;
379        verify_release_commit(release_tag, &manifest.commit)?;
380        let checksum_sha256 = sha256_hex(&checksum_bytes);
381        if !constant_time_eq(
382            checksum_sha256.as_bytes(),
383            manifest.checksums_sha256.to_ascii_lowercase().as_bytes(),
384        ) {
385            return Err("SHA256SUMS digest does not match release manifest".to_string());
386        }
387        let sbom_url = find_asset_url(release, "SBOM.cdx.json")
388            .ok_or_else(|| "SBOM.cdx.json is required for binary updates".to_string())?;
389        let sbom_sha256 = sha256_hex(&download_bytes(&sbom_url)?);
390        if !constant_time_eq(
391            sbom_sha256.as_bytes(),
392            manifest.sbom_sha256.to_ascii_lowercase().as_bytes(),
393        ) {
394            return Err("SBOM digest does not match release manifest".to_string());
395        }
396        let checksum_text = String::from_utf8(checksum_bytes)
397            .map_err(|_| "SHA256SUMS is not valid UTF-8".to_string())?;
398        let expected = parse_sha256sums(&checksum_text, asset_name)
399            .ok_or_else(|| format!("SHA256SUMS has no unique entry for {asset_name}"))?;
400        if !constant_time_eq(expected.as_bytes(), computed.as_bytes())
401            || !constant_time_eq(expected.as_bytes(), artifact.sha256.as_bytes())
402        {
403            return Err(format!("SHA256SUMS digest mismatch for {asset_name}"));
404        }
405        Ok(VerifiedArtifact {
406            archive_sha256: computed,
407            manifest_sha256,
408            release_commit: manifest.commit,
409            payload_sha256: artifact.payload_sha256.clone().ok_or_else(|| {
410                format!("release manifest omits extracted payload digest for {asset_name}")
411            })?,
412        })
413    }
414}
415
416#[cfg(feature = "secure-update")]
417fn fetch_api_json(url: &str) -> Result<serde_json::Value, String> {
418    const API_PREFIX: &str = "https://api.github.com/repos/yvgude/lean-ctx/";
419    if !url.starts_with(API_PREFIX) {
420        return Err(format!(
421            "refusing GitHub API URL outside canonical repository: {url}"
422        ));
423    }
424    github_api_json(url)
425}
426
427/// Whether `target` is an update for the running `current` build. Without an
428/// explicit version only a newer release counts: a build ahead of GitHub's
429/// "latest" (a fresh release before it is marked latest, a development build)
430/// must never be "updated" — or, on a schedule, silently downgraded — to an
431/// older one. `lean-ctx update <version>` and `--pin` install any other
432/// version on purpose.
433fn offers_update(target: &str, current: &str, pinned: bool) -> bool {
434    let target = target.trim_start_matches('v');
435    if pinned {
436        target != current
437    } else {
438        crate::core::version_check::is_newer(target, current)
439    }
440}
441
442#[cfg(feature = "secure-update")]
443fn verify_release_commit(release_tag: &str, expected_commit: &str) -> Result<(), String> {
444    let tag = release_tag.trim_start_matches('v');
445    let reference = fetch_api_json(&format!(
446        "https://api.github.com/repos/yvgude/lean-ctx/git/ref/tags/v{tag}"
447    ))?;
448    let object = reference
449        .get("object")
450        .ok_or_else(|| "GitHub tag reference has no object".to_string())?;
451    let object_sha = object
452        .get("sha")
453        .and_then(serde_json::Value::as_str)
454        .ok_or_else(|| "GitHub tag reference has no object SHA".to_string())?;
455    let commit = match object.get("type").and_then(serde_json::Value::as_str) {
456        Some("commit") => object_sha.to_string(),
457        Some("tag") => {
458            let tag_object = fetch_api_json(&format!(
459                "https://api.github.com/repos/yvgude/lean-ctx/git/tags/{object_sha}"
460            ))?;
461            tag_object["object"]["sha"]
462                .as_str()
463                .ok_or_else(|| "annotated GitHub tag has no commit object".to_string())?
464                .to_string()
465        }
466        Some(other) => return Err(format!("unsupported GitHub tag object type `{other}`")),
467        None => return Err("GitHub tag reference has no object type".to_string()),
468    };
469    if commit.len() != 40
470        || !commit.chars().all(|c| c.is_ascii_hexdigit())
471        || !constant_time_eq(
472            commit.to_ascii_lowercase().as_bytes(),
473            expected_commit.to_ascii_lowercase().as_bytes(),
474        )
475    {
476        return Err("release manifest commit does not match the signed GitHub tag".to_string());
477    }
478    Ok(())
479}
480
481#[cfg(feature = "secure-update")]
482fn validate_manifest(
483    manifest: &ReleaseManifest,
484    release_tag: &str,
485    asset_name: &str,
486) -> Result<(), String> {
487    if manifest.schema_version != "leanctx.release-manifest/v1" {
488        return Err(format!(
489            "unsupported release manifest schema `{}`",
490            manifest.schema_version
491        ));
492    }
493    if manifest.tag.trim_start_matches('v') != release_tag.trim_start_matches('v') {
494        return Err(format!(
495            "manifest tag `{}` does not match release tag `{release_tag}`",
496            manifest.tag
497        ));
498    }
499    if manifest.commit.len() != 40 || !manifest.commit.chars().all(|c| c.is_ascii_hexdigit()) {
500        return Err("release manifest commit is not a 40-character hexadecimal SHA".to_string());
501    }
502    if manifest.checksums_sha256.len() != 64
503        || !manifest
504            .checksums_sha256
505            .chars()
506            .all(|c| c.is_ascii_hexdigit())
507    {
508        return Err("release manifest has an invalid checksums_sha256 digest".to_string());
509    }
510    if manifest.sbom_sha256.len() != 64
511        || !manifest.sbom_sha256.chars().all(|c| c.is_ascii_hexdigit())
512    {
513        return Err("release manifest has an invalid sbom_sha256 digest".to_string());
514    }
515    let artifact = manifest
516        .artifacts
517        .get(asset_name)
518        .ok_or_else(|| format!("release manifest omits {asset_name}"))?;
519    if artifact.sha256.len() != 64 || !artifact.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
520        return Err(format!(
521            "release manifest has invalid digest for {asset_name}"
522        ));
523    }
524    let payload_sha256 = artifact.payload_sha256.as_ref().ok_or_else(|| {
525        format!("release manifest omits extracted payload digest for {asset_name}")
526    })?;
527    if payload_sha256.len() != 64 || !payload_sha256.chars().all(|c| c.is_ascii_hexdigit()) {
528        return Err(format!(
529            "release manifest has invalid extracted payload digest for {asset_name}"
530        ));
531    }
532    Ok(())
533}
534
535#[cfg(feature = "secure-update")]
536fn parse_sha256sums(text: &str, asset_name: &str) -> Option<String> {
537    let mut found = None;
538    for line in text.lines() {
539        let l = line.trim();
540        if l.is_empty() || l.starts_with('#') {
541            continue;
542        }
543        let mut parts = l.split_whitespace();
544        let hash = parts.next().unwrap_or("");
545        let file = parts.next().unwrap_or("");
546        if file == asset_name {
547            if found.is_some() || hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
548                return None;
549            }
550            found = Some(hash.to_ascii_lowercase());
551        }
552    }
553    found
554}
555
556pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
557    use sha2::{Digest, Sha256};
558    let mut h = Sha256::new();
559    h.update(bytes);
560    let out = h.finalize();
561    hex_lower(&out)
562}
563
564fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
565    if a.len() != b.len() {
566        return false;
567    }
568    a.iter()
569        .zip(b.iter())
570        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
571        == 0
572}
573
574fn hex_lower(bytes: &[u8]) -> String {
575    const HEX: &[u8; 16] = b"0123456789abcdef";
576    let mut out = String::with_capacity(bytes.len() * 2);
577    for &b in bytes {
578        out.push(HEX[(b >> 4) as usize] as char);
579        out.push(HEX[(b & 0x0f) as usize] as char);
580    }
581    out
582}
583
584fn canonical_state_dir() -> Result<PathBuf, String> {
585    let state = crate::core::paths::state_dir()?;
586    std::fs::create_dir_all(&state).map_err(|e| {
587        format!(
588            "cannot create update state directory {}: {e}",
589            state.display()
590        )
591    })?;
592    reject_symlink(&state)?;
593    std::fs::canonicalize(&state).map_err(|e| format!("cannot resolve state directory: {e}"))
594}
595
596fn update_layout(state_dir: &Path) -> Result<(), String> {
597    let updates = state_dir.join("updates");
598    let previous = updates.join("previous");
599    let staged = updates.join("staged");
600    for directory in [&updates, &previous, &staged] {
601        std::fs::create_dir_all(directory).map_err(|e| {
602            format!(
603                "cannot create update directory {}: {e}",
604                directory.display()
605            )
606        })?;
607        reject_symlink(directory)?;
608    }
609    Ok(())
610}
611
612fn update_transaction_path() -> Result<PathBuf, String> {
613    let state = canonical_state_dir()?;
614    update_layout(&state)?;
615    let path = state.join(UPDATE_TRANSACTION_FILE);
616    reject_symlink_if_present(&path)?;
617    Ok(path)
618}
619
620fn update_lock_path() -> Result<PathBuf, String> {
621    let state = canonical_state_dir()?;
622    update_layout(&state)?;
623    let path = state.join(UPDATE_LOCK_FILE);
624    reject_symlink_if_present(&path)?;
625    Ok(path)
626}
627
628fn staged_update_path(operation: &str, backup: bool) -> Result<PathBuf, String> {
629    if operation != "update" && operation != "rollback" {
630        return Err(format!("unsupported update operation `{operation}`"));
631    }
632    let state = canonical_state_dir()?;
633    update_layout(&state)?;
634    let suffix = if backup { "backup" } else { "target" };
635    Ok(state
636        .join("updates")
637        .join("staged")
638        .join(format!("{operation}-{suffix}.bin")))
639}
640
641fn previous_binary_path(state_path: &Path, current_exe: &Path) -> Result<PathBuf, String> {
642    let name = current_exe
643        .file_name()
644        .ok_or_else(|| "current executable has no file name".to_string())?;
645    Ok(state_path.join("updates").join("previous").join(name))
646}
647
648fn canonical_current_exe(current_exe: &Path) -> Result<PathBuf, String> {
649    let metadata = std::fs::symlink_metadata(current_exe).map_err(|e| {
650        format!(
651            "cannot inspect current executable {}: {e}",
652            current_exe.display()
653        )
654    })?;
655    if metadata.file_type().is_symlink() {
656        return Err("current executable is a symlink; refusing update".to_string());
657    }
658    std::fs::canonicalize(current_exe).map_err(|e| {
659        format!(
660            "cannot resolve current executable {}: {e}",
661            current_exe.display()
662        )
663    })
664}
665
666fn reject_symlink(path: &Path) -> Result<(), String> {
667    let metadata = std::fs::symlink_metadata(path)
668        .map_err(|e| format!("cannot inspect update path {}: {e}", path.display()))?;
669    if metadata.file_type().is_symlink() {
670        return Err(format!("update path is a symlink: {}", path.display()));
671    }
672    Ok(())
673}
674
675fn reject_symlink_if_present(path: &Path) -> Result<(), String> {
676    match std::fs::symlink_metadata(path) {
677        Ok(metadata) if metadata.file_type().is_symlink() => {
678            Err(format!("update path is a symlink: {}", path.display()))
679        }
680        Ok(_) => Ok(()),
681        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
682        Err(e) => Err(format!(
683            "cannot inspect update path {}: {e}",
684            path.display()
685        )),
686    }
687}
688
689fn ensure_no_symlink_under(root: &Path, path: &Path) -> Result<(), String> {
690    let relative = path
691        .strip_prefix(root)
692        .map_err(|_| format!("update path escapes state directory: {}", path.display()))?;
693    let root = std::fs::canonicalize(root)
694        .map_err(|e| format!("cannot resolve update root {}: {e}", root.display()))?;
695    let mut cursor = root;
696    for component in relative.components() {
697        match component {
698            std::path::Component::Normal(component) => cursor.push(component),
699            std::path::Component::CurDir => continue,
700            _ => {
701                return Err(format!(
702                    "update path contains unsafe component: {}",
703                    path.display()
704                ));
705            }
706        }
707        reject_symlink_if_present(&cursor)?;
708    }
709    Ok(())
710}
711
712fn canonical_update_paths(current_exe: &Path) -> Result<(PathBuf, PathBuf, PathBuf), String> {
713    let state = canonical_state_dir()?;
714    update_layout(&state)?;
715    let current = canonical_current_exe(current_exe)?;
716    let receipt = state.join(UPDATE_RECEIPT_FILE);
717    let previous = previous_binary_path(&state, &current)?;
718    ensure_no_symlink_under(&state, &receipt)?;
719    ensure_no_symlink_under(&state, &previous)?;
720    Ok((state, receipt, previous))
721}
722
723fn validate_receipt_paths(
724    receipt: &UpdateReceipt,
725    state_dir: &Path,
726    current_exe: &Path,
727) -> Result<(), String> {
728    let current = canonical_current_exe(current_exe)?;
729    let previous = previous_binary_path(state_dir, &current)?;
730    if Path::new(&receipt.active.path) != current {
731        return Err("active receipt path is not the canonical executable".to_string());
732    }
733    if Path::new(&receipt.previous.path) != previous {
734        return Err("previous receipt path is not the canonical retained binary".to_string());
735    }
736    ensure_no_symlink_under(state_dir, &previous)?;
737    Ok(())
738}
739
740fn load_update_receipt() -> Result<Option<UpdateReceipt>, String> {
741    let current = std::env::current_exe().map_err(|e| e.to_string())?;
742    load_update_receipt_for(&current)
743}
744
745fn load_update_receipt_for(current_exe: &Path) -> Result<Option<UpdateReceipt>, String> {
746    let (state, path, _) = canonical_update_paths(current_exe)?;
747    let raw = match std::fs::read_to_string(&path) {
748        Ok(raw) => raw,
749        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
750        Err(e) => return Err(format!("{}: {e}", path.display())),
751    };
752    let receipt: UpdateReceipt =
753        serde_json::from_str(&raw).map_err(|e| format!("invalid {}: {e}", path.display()))?;
754    validate_receipt(&receipt)?;
755    validate_receipt_integrity(&receipt)?;
756    validate_receipt_paths(&receipt, &state, current_exe)?;
757    Ok(Some(receipt))
758}
759
760fn validate_receipt(receipt: &UpdateReceipt) -> Result<(), String> {
761    if receipt.schema_version != UPDATE_RECEIPT_SCHEMA
762        && receipt.schema_version != LEGACY_UPDATE_RECEIPT_SCHEMA
763    {
764        return Err(format!(
765            "unsupported update receipt schema `{}`",
766            receipt.schema_version
767        ));
768    }
769    for (label, binary) in [("active", &receipt.active), ("previous", &receipt.previous)] {
770        if binary.sha256.len() != 64 || !binary.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
771            return Err(format!("{label} receipt has an invalid binary digest"));
772        }
773        if binary.size == 0 || binary.path.trim().is_empty() || binary.version.trim().is_empty() {
774            return Err(format!("{label} receipt has missing identity or size"));
775        }
776        if let Some(digest) = &binary.manifest_sha256
777            && (digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()))
778        {
779            return Err(format!("{label} receipt has an invalid manifest digest"));
780        }
781        if let Some(digest) = &binary.archive_sha256
782            && (digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()))
783        {
784            return Err(format!("{label} receipt has an invalid archive digest"));
785        }
786        if let Some(commit) = &binary.release_commit
787            && (commit.len() != 40 || !commit.chars().all(|c| c.is_ascii_hexdigit()))
788        {
789            return Err(format!("{label} receipt has an invalid release commit"));
790        }
791    }
792    if let Some(digest) = &receipt.receipt_sha256
793        && (digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()))
794    {
795        return Err("update receipt has an invalid integrity digest".to_string());
796    }
797    Ok(())
798}
799
800fn receipt_digest(receipt: &UpdateReceipt) -> Result<String, String> {
801    let mut unsigned = receipt.clone();
802    unsigned.receipt_sha256 = None;
803    let bytes = serde_json::to_vec(&unsigned).map_err(|e| e.to_string())?;
804    Ok(sha256_hex(&bytes))
805}
806
807fn validate_receipt_integrity(receipt: &UpdateReceipt) -> Result<(), String> {
808    if receipt.schema_version == LEGACY_UPDATE_RECEIPT_SCHEMA {
809        return Ok(());
810    }
811    let actual = receipt
812        .receipt_sha256
813        .as_deref()
814        .ok_or_else(|| "v2 update receipt is missing its integrity digest".to_string())?;
815    let expected = receipt_digest(receipt)?;
816    if !constant_time_eq(actual.as_bytes(), expected.as_bytes()) {
817        return Err("update receipt integrity digest mismatch".to_string());
818    }
819    Ok(())
820}
821
822pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> {
823    let parent = path
824        .parent()
825        .ok_or_else(|| format!("{} has no parent directory", path.display()))?;
826    std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
827    reject_symlink_if_present(parent)?;
828    reject_symlink_if_present(path)?;
829    let name = path
830        .file_name()
831        .and_then(|n| n.to_str())
832        .ok_or_else(|| format!("{} has no valid file name", path.display()))?;
833    let tmp = parent.join(format!(".{name}.tmp"));
834    let mut file = std::fs::OpenOptions::new()
835        .write(true)
836        .create_new(true)
837        .open(&tmp)
838        .map_err(|e| format!("{}: {e}", tmp.display()))?;
839    let result = (|| {
840        file.write_all(bytes).map_err(|e| e.to_string())?;
841        file.sync_all().map_err(|e| e.to_string())?;
842        drop(file);
843        std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
844        #[cfg(unix)]
845        std::fs::File::open(parent)
846            .and_then(|dir| dir.sync_all())
847            .map_err(|e| e.to_string())?;
848        Ok::<(), String>(())
849    })();
850    if result.is_err() {
851        let _ = std::fs::remove_file(&tmp);
852    }
853    result.map_err(|e| format!("{}: {e}", path.display()))
854}
855
856/// Builds the GitHub Releases API URL: the latest release when `version` is
857/// `None`, or a specific tag (`v{version}`) when pinned (#447). The leading
858/// `v` is normalised so both `3.8.5` and `v3.8.5` resolve to the `v3.8.5` tag.
859fn release_api_url(version: Option<&str>) -> String {
860    match version {
861        None => GITHUB_API_RELEASES.to_string(),
862        Some(v) => {
863            let core = v.trim_start_matches('v');
864            format!("https://api.github.com/repos/yvgude/lean-ctx/releases/tags/v{core}")
865        }
866    }
867}
868
869/// ureq agent that trusts the OS store (incl. corporate proxy CAs) via the
870/// platform verifier. ureq's default `RootCerts::WebPki` ignores the system
871/// store, so updates fail with `UnknownIssuer` behind TLS-intercepting proxies.
872fn https_agent() -> ureq::Agent {
873    // Bound the connection-setup phases so a dead network, a stuck DNS
874    // resolver or an unresponsive server can never make `lean-ctx update`
875    // hang indefinitely (the reported "stuck updating"). These cap DNS,
876    // TCP connect and time-to-first-byte only — a large but *progressing*
877    // binary download is intentionally NOT limited (no global / recv-body
878    // cap on this shared agent), so slow links still complete.
879    crate::core::http_client::ureq_agent_with_timeouts(
880        Some(std::time::Duration::from_secs(15)),
881        Some(std::time::Duration::from_secs(20)),
882        Some(std::time::Duration::from_secs(30)),
883    )
884}
885
886/// Fetches release metadata from GitHub. `version = None` returns the latest
887/// release; `Some(v)` returns the specific tagged release for version pinning.
888fn fetch_release(version: Option<&str>) -> Result<serde_json::Value, String> {
889    github_api_json(&release_api_url(version))
890}
891
892/// Extracts an explicit version argument from `update` args, if present.
893/// Returns the first positional token (one that is not a `--flag`); the
894/// `--schedule` subcommand is consumed earlier so it never reaches here.
895fn parse_target_version(args: &[String]) -> Option<&str> {
896    args.iter()
897        .map(String::as_str)
898        .find(|a| !a.starts_with('-'))
899}
900
901/// True if `s` looks like a release version (optionally `v`-prefixed, e.g.
902/// `3.8.5` / `v3.8.5` / `3.8.5-rc1`), so typos are rejected before the API call.
903fn looks_like_version(s: &str) -> bool {
904    let core = s.strip_prefix('v').unwrap_or(s);
905    core.contains('.')
906        && core.starts_with(|c: char| c.is_ascii_digit())
907        && core
908            .chars()
909            .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c.is_ascii_alphabetic())
910}
911
912fn find_asset_url(release: &serde_json::Value, asset_name: &str) -> Option<String> {
913    release["assets"]
914        .as_array()?
915        .iter()
916        .find(|a| a["name"].as_str() == Some(asset_name))
917        .and_then(|a| a["browser_download_url"].as_str())
918        .map(std::string::ToString::to_string)
919}
920
921fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
922    if !url.starts_with("https://github.com/yvgude/lean-ctx/") {
923        return Err(format!("refusing release asset from unexpected URL: {url}"));
924    }
925    let response = https_agent()
926        .get(url)
927        .header("User-Agent", &format!("lean-ctx/{CURRENT_VERSION}"))
928        .call()
929        .map_err(|e| e.to_string())?;
930
931    let mut bytes = Vec::new();
932    response
933        .into_body()
934        .into_reader()
935        .read_to_end(&mut bytes)
936        .map_err(|e| e.to_string())?;
937    Ok(bytes)
938}
939
940fn extract_binary(archive_bytes: &[u8], asset_name: &str) -> Result<Vec<u8>, String> {
941    if Path::new(asset_name)
942        .extension()
943        .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
944    {
945        extract_from_zip(archive_bytes)
946    } else {
947        extract_from_tar_gz(archive_bytes)
948    }
949}
950
951/// #356: on macOS, sign the staged update with the persistent identity (ad-hoc
952/// fallback) so the TCC grant survives the update. Done before the swap: a
953/// signature added after it would change the installed bytes behind the
954/// transaction's back, and the commit check would refuse the update.
955/// Returns the bytes as they will be installed.
956fn sign_staged_binary(staged_path: &std::path::Path, bytes: &[u8]) -> Result<Vec<u8>, String> {
957    #[cfg(target_os = "macos")]
958    let installed = {
959        let _ = bytes;
960        // Which path signed is not consulted. That signing is over is: when a
961        // signing tool could not be confirmed ended it may still be writing
962        // the staged file, and then nothing is read, sealed or swapped.
963        crate::core::codesign::sign_binary(staged_path)
964            .map_err(|stopped| stopped.to_string())
965            .and_then(|_| {
966                std::fs::read(staged_path)
967                    .map_err(|e| format!("cannot read signed staged binary: {e}"))
968            })
969    };
970    #[cfg(not(target_os = "macos"))]
971    let installed = {
972        let _ = staged_path;
973        Ok(bytes.to_vec())
974    };
975    installed
976}
977
978fn replace_staged_binary(
979    staged_path: &std::path::Path,
980    current_exe: &std::path::Path,
981) -> Result<(), String> {
982    reject_symlink_if_present(staged_path)?;
983    let staged = std::fs::metadata(staged_path).map_err(|e| {
984        format!(
985            "cannot inspect staged binary {}: {e}",
986            staged_path.display()
987        )
988    })?;
989    if staged.len() == 0 {
990        return Err("refusing to install an empty staged binary".to_string());
991    }
992    #[cfg(unix)]
993    {
994        use std::os::unix::fs::PermissionsExt;
995        let _ = std::fs::set_permissions(staged_path, std::fs::Permissions::from_mode(0o755));
996    }
997
998    // On Windows, a running executable can be renamed but not overwritten.
999    // Move the current binary out of the way first, then move the new one in.
1000    // If the file is locked (MCP server running), try stopping managed processes
1001    // first, then schedule a deferred update as last resort.
1002    #[cfg(windows)]
1003    {
1004        // The sidecar of the previous swap is still there: its process could
1005        // not delete the image it was running from (#2048).
1006        let old_path = windows_sidecar::clear_sidecar(current_exe)?;
1007
1008        if let Ok(()) = std::fs::rename(current_exe, &old_path) {
1009            if let Err(e) = std::fs::rename(staged_path, current_exe) {
1010                let _ = std::fs::rename(&old_path, current_exe);
1011                return Err(format!("Cannot place new binary: {e}"));
1012            }
1013            Ok(())
1014        } else {
1015            // Binary is locked. Try to stop managed processes first.
1016            eprintln!("\nBinary is locked. Stopping managed lean-ctx processes...");
1017            stop_managed_windows_processes();
1018
1019            // Brief wait for processes to release file handles.
1020            std::thread::sleep(std::time::Duration::from_millis(1500));
1021
1022            // Retry after stopping.
1023            if let Ok(()) = std::fs::rename(current_exe, &old_path) {
1024                if let Err(e) = std::fs::rename(staged_path, current_exe) {
1025                    let _ = std::fs::rename(&old_path, current_exe);
1026                    return Err(format!("Cannot place new binary: {e}"));
1027                }
1028                Ok(())
1029            } else {
1030                // Still locked (likely MCP server held by editor).
1031                print_blocking_processes(current_exe);
1032                deferred_windows_update(staged_path, current_exe)
1033            }
1034        }
1035    }
1036
1037    #[cfg(not(windows))]
1038    {
1039        // Same-filesystem rename is atomic and retains the old inode on
1040        // Unix/macOS, so a failed swap cannot leave the install path absent.
1041        // The staged file is installed byte for byte: on macOS it was signed
1042        // while staged (`sign_staged_binary`), so the receipt and the commit
1043        // check see exactly the installed bytes.
1044        std::fs::rename(staged_path, current_exe).map_err(|e| {
1045            let _ = std::fs::remove_file(staged_path);
1046            format!("Cannot replace binary (permission denied?): {e}")
1047        })?;
1048        Ok(())
1049    }
1050}
1051
1052/// Try to stop managed lean-ctx processes (proxy, serve, daemon) on Windows
1053/// before attempting a deferred update.
1054#[cfg(windows)]
1055fn stop_managed_windows_processes() {
1056    // Try `lean-ctx stop` first — it's the cleanest shutdown path.
1057    let stop_result = std::process::Command::new("lean-ctx").arg("stop").output();
1058
1059    // `lean-ctx stop` stops only the services this instance recorded. There
1060    // is no name-based fallback (`taskkill /IM lean-ctx.exe`): it reaches the
1061    // MCP servers of running editor sessions (#2176, owner decision 02.11.).
1062    // What still holds the binary is listed by `print_blocking_processes`.
1063    match stop_result {
1064        Ok(out) if out.status.success() => {
1065            eprintln!("  Managed processes stopped.");
1066        }
1067        _ => {
1068            eprintln!("  Could not stop every lean-ctx service (see `lean-ctx stop`).");
1069        }
1070    }
1071}
1072
1073/// Print which lean-ctx.exe processes are blocking the update on Windows.
1074#[cfg(windows)]
1075fn print_blocking_processes(target_exe: &std::path::Path) {
1076    let target_name = target_exe
1077        .file_name()
1078        .and_then(|n| n.to_str())
1079        .unwrap_or("lean-ctx.exe");
1080
1081    let output = std::process::Command::new("tasklist")
1082        .args([
1083            "/FI",
1084            &format!("IMAGENAME eq {target_name}"),
1085            "/V",
1086            "/FO",
1087            "CSV",
1088        ])
1089        .output();
1090
1091    if let Ok(out) = output {
1092        let stdout = String::from_utf8_lossy(&out.stdout);
1093        let lines: Vec<&str> = stdout.lines().skip(1).collect(); // skip CSV header
1094        if !lines.is_empty() {
1095            eprintln!("\n  Blocking lean-ctx processes:");
1096            for line in &lines {
1097                // CSV: "Image Name","PID","Session Name","Session#","Mem Usage","Status","User Name","CPU Time","Window Title"
1098                let fields: Vec<&str> = line.split(',').collect();
1099                if fields.len() >= 2 {
1100                    let pid = fields[1].trim_matches('"');
1101                    eprintln!("    PID {pid}");
1102                }
1103            }
1104            eprintln!("\n  To stop manually: taskkill /F /PID <pid>  (or close your editor)");
1105        }
1106    }
1107}
1108
1109/// On Windows, when the binary is locked by an MCP server, we can't rename it.
1110/// Instead, stage the new binary and spawn a background cmd process that waits
1111/// for the lock to be released (with a timeout), then performs the swap.
1112#[cfg(windows)]
1113fn deferred_windows_update(
1114    staged_path: &std::path::Path,
1115    target_exe: &std::path::Path,
1116) -> Result<(), String> {
1117    let target_str = target_exe.display().to_string();
1118    let staged_str = staged_path.display().to_string();
1119    let old_path = windows_sidecar::clear_sidecar(target_exe)?;
1120    let old_str = old_path.display().to_string();
1121    let transaction_str = update_transaction_path()?.display().to_string();
1122    let lock_str = update_lock_path()?.display().to_string();
1123    let max_retries = 60;
1124
1125    let script = generate_deferred_bat_script(
1126        &target_str,
1127        &staged_str,
1128        &old_str,
1129        &transaction_str,
1130        &lock_str,
1131        max_retries,
1132    );
1133
1134    let script_path = target_exe.with_file_name("lean-ctx-update.bat");
1135    reject_symlink_if_present(&script_path)?;
1136    std::fs::write(&script_path, &script)
1137        .map_err(|e| format!("Cannot write update script: {e}"))?;
1138
1139    let _ = std::process::Command::new("cmd")
1140        .args(["/C", "start", "/MIN", &script_path.display().to_string()])
1141        .spawn();
1142
1143    println!("\nThe binary is still in use (likely by your editor's MCP server).");
1144    println!("A background update has been scheduled (timeout: {max_retries}s).");
1145    println!("Close your editor and the update will complete automatically.");
1146    println!("\nIf it times out, run: lean-ctx update");
1147    println!("Update script: {}", script_path.display());
1148
1149    // The helper runs after this process exits; do not commit a receipt that
1150    // claims the staged file is active before the helper has completed the swap.
1151    Err("update deferred until the running Windows binary is released".to_string())
1152}
1153
1154fn extract_from_tar_gz(data: &[u8]) -> Result<Vec<u8>, String> {
1155    use flate2::read::GzDecoder;
1156
1157    let gz = GzDecoder::new(data);
1158    let mut archive = tar::Archive::new(gz);
1159
1160    for entry in archive.entries().map_err(|e| e.to_string())? {
1161        let mut entry = entry.map_err(|e| e.to_string())?;
1162        let path = entry.path().map_err(|e| e.to_string())?;
1163        let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1164
1165        if name == "lean-ctx" || name == "lean-ctx.exe" {
1166            let mut bytes = Vec::new();
1167            entry.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
1168            return Ok(bytes);
1169        }
1170    }
1171    Err("lean-ctx binary not found inside archive".to_string())
1172}
1173
1174fn extract_from_zip(data: &[u8]) -> Result<Vec<u8>, String> {
1175    use std::io::Cursor;
1176
1177    let cursor = Cursor::new(data);
1178    let mut zip = zip::ZipArchive::new(cursor).map_err(|e| e.to_string())?;
1179
1180    for i in 0..zip.len() {
1181        let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
1182        let name = file.name().map_err(|e| e.to_string())?.into_owned();
1183        if name == "lean-ctx.exe" || name == "lean-ctx" {
1184            let mut bytes = Vec::new();
1185            file.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
1186            return Ok(bytes);
1187        }
1188    }
1189    Err("lean-ctx binary not found inside zip archive".to_string())
1190}
1191
1192/// Generate the deferred update batch script content (extracted for testability).
1193#[cfg(any(windows, test))]
1194fn generate_deferred_bat_script(
1195    target: &str,
1196    staged: &str,
1197    old: &str,
1198    transaction: &str,
1199    lock: &str,
1200    max_retries: u32,
1201) -> String {
1202    format!(
1203        r#"@echo off
1204setlocal
1205set "RETRIES=0"
1206set "MAX_RETRIES={max_retries}"
1207set "LEANCTX_UPDATE_LOCK={lock}"
1208set "LEANCTX_UPDATE_TARGET={target}"
1209set "LEANCTX_UPDATE_STAGED={staged}"
1210set "LEANCTX_UPDATE_OLD={old}"
1211set "LEANCTX_UPDATE_TRANSACTION={transaction}"
1212
1213echo lean-ctx update: waiting for binary to be released (timeout: %MAX_RETRIES%s)...
1214echo.
1215echo Blocking processes:
1216tasklist /FI "IMAGENAME eq lean-ctx.exe" /V /NH 2>nul
1217echo.
1218echo Close your editor (Cursor, VS Code, etc.) to release the binary,
1219echo or stop manually:  lean-ctx stop
1220echo.
1221
1222:retry
1223if %RETRIES% GEQ %MAX_RETRIES% goto timeout
1224set /a RETRIES+=1
1225timeout /t 1 /nobreak >nul
1226powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ^
1227  "$ErrorActionPreference = 'Stop'; $lock = $null; $targetMoved = $false; $stagedMoved = $false; try {{ $lock = [System.IO.File]::Open($env:LEANCTX_UPDATE_LOCK, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None); Move-Item -LiteralPath $env:LEANCTX_UPDATE_TARGET -Destination $env:LEANCTX_UPDATE_OLD -Force; $targetMoved = $true; Move-Item -LiteralPath $env:LEANCTX_UPDATE_STAGED -Destination $env:LEANCTX_UPDATE_TARGET -Force; $stagedMoved = $true; & $env:LEANCTX_UPDATE_TARGET update --recover-update --lock-held; if ($LASTEXITCODE -ne 0) {{ throw 'receipt recovery failed' }}; if (Test-Path -LiteralPath $env:LEANCTX_UPDATE_OLD) {{ Remove-Item -LiteralPath $env:LEANCTX_UPDATE_OLD -Force -ErrorAction SilentlyContinue }} }} catch {{ if ($stagedMoved -and (Test-Path -LiteralPath $env:LEANCTX_UPDATE_TARGET) -and -not (Test-Path -LiteralPath $env:LEANCTX_UPDATE_STAGED)) {{ Move-Item -LiteralPath $env:LEANCTX_UPDATE_TARGET -Destination $env:LEANCTX_UPDATE_STAGED -Force }}; if ($targetMoved -and (Test-Path -LiteralPath $env:LEANCTX_UPDATE_OLD) -and -not (Test-Path -LiteralPath $env:LEANCTX_UPDATE_TARGET)) {{ Move-Item -LiteralPath $env:LEANCTX_UPDATE_OLD -Destination $env:LEANCTX_UPDATE_TARGET -Force }}; exit 1 }} finally {{ if ($null -ne $lock) {{ $lock.Dispose() }} }}"
1228if errorlevel 1 (
1229    if %RETRIES% EQU 10 echo   Still waiting... (%RETRIES%/%MAX_RETRIES%s)
1230    if %RETRIES% EQU 30 echo   Still waiting... (%RETRIES%/%MAX_RETRIES%s) — try closing your editor
1231    if %RETRIES% EQU 50 echo   Still waiting... (%RETRIES%/%MAX_RETRIES%s) — timeout approaching
1232    goto retry
1233)
1234
1235echo.
1236echo Updated successfully!
1237goto cleanup
1238
1239:timeout
1240echo.
1241echo Update timed out after %MAX_RETRIES% seconds.
1242echo The new binary is staged at: {staged}
1243echo.
1244echo To complete the update manually:
1245echo   1. Close your editor (Cursor, VS Code, etc.)
1246echo   2. Run: lean-ctx update
1247echo.
1248echo Or run: lean-ctx update --force
1249echo.
1250pause
1251exit /b 1
1252
1253:cleanup
1254del "%~f0" >nul 2>&1
1255"#
1256    )
1257}
1258
1259#[cfg(test)]
1260#[path = "updater/tests.rs"]
1261mod tests;