Skip to main content

rootle_manager/
install.rs

1//! The network flows: release install (krew atomicity), local symlink
2//! install, and the update/upgrade cycle.
3
4use super::reference::{ProviderReference, binary_name_of};
5use super::release::{
6    checksum_sidecar, download_bytes, extract_binary, latest_release, latest_release_at,
7    pick_asset, platform_target, release_by_tag_at, sha256_hex, verify_checksum,
8};
9use super::store::now_iso;
10use super::{Manager, ManagerError, Receipt, Result, SweepOutcome};
11use std::path::Path;
12
13impl Manager {
14    /// Install (or upgrade to a specific tag). The krew atomicity
15    /// sequence: staging → verify → extract → receipt LAST → swap.
16    /// Plain-HTTP tarball refs stay on the CLI path (deliberate
17    /// deployments, plans/0014); everything else flows through
18    /// [`install_inner`] with a live Ui.
19    pub fn install(&self, r: &ProviderReference, force: bool) -> Result<Receipt> {
20        if let Some(url) = &r.tarball {
21            return self.install_tarball(r, url, force);
22        }
23        self.install_inner(r, force, &crate::progress::ProgressOutput::new(), None)
24    }
25
26    /// The release-install flow with the Ui swapped in — the
27    /// `update_inner` pattern: `rootle update`'s sweep and the TUI's
28    /// consent install (0019) drive the same verified flow, the
29    /// latter through a silent recorder Ui.
30    pub fn install_inner(
31        &self,
32        r: &ProviderReference,
33        force: bool,
34        ui: &crate::progress::ProgressOutput,
35        expect_sha: Option<&str>,
36    ) -> Result<Receipt> {
37        if let Some(existing) = self.receipt(&r.name)
38            && existing.tag == r.tag.clone().unwrap_or_default()
39            && !force
40            && existing.source == r.repo
41        {
42            return Err(ManagerError::User(format!(
43                "{} {} is already installed (use --force to reinstall)",
44                r.name, existing.tag
45            )));
46        }
47        let timer = crate::progress::Timer::start();
48        let release = match &r.tag {
49            Some(tag) => release_by_tag_at(&self.api, &r.repo, tag)?,
50            None => latest_release_at(&self.api, &r.repo)?,
51        };
52        ui.done(
53            "Resolved",
54            &format!("{repo} @ {tag}", repo = r.repo, tag = release.tag_name),
55        );
56        let target = platform_target();
57        let asset = pick_asset(&release, target)?;
58        let sidecar = checksum_sidecar(&release, asset)?;
59
60        let spinner = ui.spinner(&format!("Downloading {}", asset.name));
61        let tarball = download_bytes(&asset.browser_download_url)?;
62        drop(spinner);
63        ui.step("Verifying", "sha256 checksum");
64        verify_checksum(&tarball, &sidecar.browser_download_url)?;
65        // 0019 M2: a config-pinned sha wins over the forge's own
66        // sidecar — the trust root is the committed config.
67        if let Some(want) = expect_sha {
68            let got = sha256_hex(&tarball);
69            if got != want {
70                return Err(ManagerError::User(format!(
71                    "sha256 pin mismatch: config pins {want}, release serves {got}"
72                )));
73            }
74        }
75        ui.done("Verified", &format!("sha256 ok ({})", asset.name));
76
77        ui.step("Extracting", &binary_name_of(r));
78        let binary_name = format!("rootle-{}", r.name);
79        let bytes = extract_binary(&tarball, &binary_name)?;
80        ui.done("Extracted", &binary_name);
81
82        // Versioned dir + receipt LAST + pointer swap.
83        let vdir = self.version_dir(&r.name, &release.tag_name);
84        std::fs::create_dir_all(&vdir)?;
85        let bin = vdir.join(&binary_name);
86        std::fs::write(&bin, bytes)?;
87        #[cfg(unix)]
88        {
89            use std::os::unix::fs::PermissionsExt;
90            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
91        }
92
93        let receipt = Receipt {
94            name: r.name.clone(),
95            source: r.repo.clone(),
96            tag: release.tag_name.clone(),
97            sha256: sha256_hex(&tarball),
98            pinned: r.tag.is_some(),
99            installed_at: now_iso(),
100            latest_tag: Some(release.tag_name.clone()),
101        };
102        self.write_receipt(&receipt)?;
103        self.point_current(&r.name, &release.tag_name)?;
104
105        ui.summary("Installed", &r.name, &release.tag_name, timer.elapsed());
106        ui.note(&format!(
107            "you are trusting {repo} — run `rootle provider use {name}` to activate",
108            repo = r.repo,
109            name = r.name
110        ));
111        Ok(receipt)
112    }
113
114    /// Plain-HTTP install (plans/0014 #1a): the URL names the platform
115    /// tarball; the mandatory `.sha256` sidecar rides at
116    /// `<url>.sha256`. Same krew atomicity as a release install, but
117    /// no releases API — install-and-pin: `update`/`upgrade` never
118    /// track these receipts (#1b).
119    fn install_tarball(&self, r: &ProviderReference, url: &str, force: bool) -> Result<Receipt> {
120        let file = url.rsplit('/').next().unwrap_or(url);
121        if let Some(existing) = self.receipt(&r.name)
122            && existing.source == url
123            && !force
124        {
125            return Err(ManagerError::User(format!(
126                "{} is already installed from this URL (use --force to reinstall)",
127                r.name
128            )));
129        }
130        let timer = crate::progress::Timer::start();
131        let ui = crate::progress::ProgressOutput::new();
132
133        let spinner = ui.spinner(&format!("Downloading {file}"));
134        let tarball = download_bytes(url)?;
135        drop(spinner);
136        ui.step("Verifying", "sha256 checksum");
137        verify_checksum(&tarball, &format!("{url}.sha256"))?;
138        ui.done("Verified", &format!("sha256 ok ({file})"));
139
140        ui.step("Extracting", &binary_name_of(r));
141        let binary_name = format!("rootle-{}", r.name);
142        let bytes = extract_binary(&tarball, &binary_name)?;
143        ui.done("Extracted", &binary_name);
144
145        // No release tag exists on a plain-HTTP host: the filename
146        // version when it has one, else the content id itself.
147        let tag = r
148            .tag
149            .clone()
150            .unwrap_or_else(|| sha256_hex(&tarball)[..12].to_string());
151        let vdir = self.version_dir(&r.name, &tag);
152        std::fs::create_dir_all(&vdir)?;
153        let bin = vdir.join(&binary_name);
154        std::fs::write(&bin, bytes)?;
155        #[cfg(unix)]
156        {
157            use std::os::unix::fs::PermissionsExt;
158            std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
159        }
160
161        let receipt = Receipt {
162            name: r.name.clone(),
163            source: url.to_string(),
164            tag: tag.clone(),
165            sha256: sha256_hex(&tarball),
166            pinned: true, // plain-HTTP installs are install-and-pin
167            installed_at: now_iso(),
168            latest_tag: None,
169        };
170        self.write_receipt(&receipt)?;
171        self.point_current(&r.name, &tag)?;
172
173        ui.summary("Installed", &r.name, &tag, timer.elapsed());
174        ui.note(&format!(
175            "you are trusting {url} — install-and-pin: `update`/`upgrade` do not track \
176             plain-HTTP sources; upgrades come from whatever deployed it"
177        ));
178        Ok(receipt)
179    }
180
181    /// Local dev install: symlink a binary, no network (gh's `gh
182    /// extension install .` model).
183    pub fn install_path(&self, name: &str, path: &Path) -> Result<Receipt> {
184        if !path.is_file() {
185            return Err(ManagerError::User(format!(
186                "{} is not a file",
187                path.display()
188            )));
189        }
190        #[cfg(unix)]
191        {
192            use std::os::unix::fs::PermissionsExt;
193            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
194        }
195        let vdir = self.version_dir(name, "local");
196        std::fs::create_dir_all(&vdir)?;
197        let bin = vdir.join(format!("rootle-{name}"));
198        #[cfg(unix)]
199        std::os::unix::fs::symlink(path, &bin)
200            .or_else(|_| std::fs::copy(path, &bin).map(|_| ()))
201            .map_err(ManagerError::Io)?;
202        #[cfg(not(unix))]
203        std::fs::copy(path, &bin)?;
204        let receipt = Receipt {
205            name: name.into(),
206            source: path.display().to_string(),
207            tag: "local".into(),
208            sha256: String::new(),
209            pinned: true, // local installs never auto-upgrade
210            installed_at: now_iso(),
211            latest_tag: None,
212        };
213        self.write_receipt(&receipt)?;
214        self.point_current(name, "local")?;
215        println!("{name} installed (local: {path})", path = path.display());
216        Ok(receipt)
217    }
218
219    /// krew's non-mutating `update`: refresh the latest-known tag into
220    /// receipts. Returns (name, current, latest) for the ones that are
221    /// stale.
222    pub fn update(&self, name: Option<&str>) -> Result<Vec<(String, String, String)>> {
223        let mut stale = Vec::new();
224        for receipt in self.receipts() {
225            if let Some(want) = name
226                && receipt.name != want
227            {
228                continue;
229            }
230            if receipt.pinned || !tracks_releases(&receipt.source) {
231                continue;
232            }
233            match latest_release(&receipt.source) {
234                Ok(rel) => {
235                    let latest = rel.tag_name;
236                    if latest != receipt.tag {
237                        stale.push((receipt.name.clone(), receipt.tag.clone(), latest.clone()));
238                    }
239                    let mut updated = receipt.clone();
240                    updated.latest_tag = Some(latest);
241                    self.write_receipt(&updated)?;
242                }
243                Err(e) => eprintln!("{}: {e}", receipt.name),
244            }
245        }
246        Ok(stale)
247    }
248
249    /// `upgrade`: swap the binary for the latest-known (or fresh
250    /// latest) tag. Pinned skips unless force.
251    pub fn upgrade(&self, name: Option<&str>, dry_run: bool, force: bool) -> Result<()> {
252        for receipt in self.receipts() {
253            if let Some(want) = name
254                && receipt.name != want
255            {
256                continue;
257            }
258            if !tracks_releases(&receipt.source) {
259                // plans/0014 #1b: plain-HTTP and --path installs are
260                // install-and-pin — upgrades come from whatever
261                // deployed them, never from us.
262                println!(
263                    "{}: {} source is install-and-pin, not tracked by upgrade",
264                    receipt.name, receipt.source
265                );
266                continue;
267            }
268            if receipt.pinned && !force {
269                println!("{}: pinned at {} (use --force)", receipt.name, receipt.tag);
270                continue;
271            }
272            let latest = match &receipt.latest_tag {
273                Some(t) => t.clone(),
274                None => latest_release(&receipt.source)?.tag_name,
275            };
276            if latest == receipt.tag {
277                println!("{}: {} is current", receipt.name, receipt.tag);
278                continue;
279            }
280            if dry_run {
281                println!("{}: {} → {}", receipt.name, receipt.tag, latest);
282                continue;
283            }
284            let r = ProviderReference {
285                repo: receipt.source.clone(),
286                name: receipt.name.clone(),
287                tag: None,
288                tarball: None,
289            };
290            // Force through the already-installed check.
291            self.install(&r, true)?;
292        }
293        Ok(())
294    }
295
296    /// `rootle update`'s provider sweep (0019 M1): refresh-and-upgrade
297    /// every tracked, unpinned receipt with failures isolated per
298    /// provider — one dead forge neither aborts the rest nor fails
299    /// the command's app half. Outcome rows are returned for the
300    /// caller to render; the release-install stages render through
301    /// `ui`. `dry_run` reports staleness without swapping anything.
302    pub fn sweep(&self, dry_run: bool, ui: &crate::progress::ProgressOutput) -> Vec<SweepOutcome> {
303        let mut out = Vec::new();
304        for receipt in self.receipts() {
305            if !tracks_releases(&receipt.source) {
306                out.push(SweepOutcome::Untracked {
307                    name: receipt.name.clone(),
308                    source: receipt.source.clone(),
309                });
310                continue;
311            }
312            if receipt.pinned {
313                out.push(SweepOutcome::Pinned {
314                    name: receipt.name.clone(),
315                    tag: receipt.tag.clone(),
316                });
317            }
318            let latest = match &receipt.latest_tag {
319                Some(t) => t.clone(),
320                None => match latest_release_at(&self.api, &receipt.source) {
321                    Ok(rel) => rel.tag_name,
322                    Err(e) => {
323                        out.push(SweepOutcome::Failed {
324                            name: receipt.name,
325                            error: e.to_string(),
326                        });
327                        continue;
328                    }
329                },
330            };
331            if latest == receipt.tag {
332                out.push(SweepOutcome::Current {
333                    name: receipt.name,
334                    tag: receipt.tag,
335                });
336                continue;
337            }
338            if dry_run {
339                out.push(SweepOutcome::Stale {
340                    name: receipt.name,
341                    from: receipt.tag,
342                    to: latest,
343                });
344                continue;
345            }
346            let r = ProviderReference {
347                repo: receipt.source.clone(),
348                name: receipt.name.clone(),
349                tag: None,
350                tarball: None,
351            };
352            // Force through the already-installed check; the UI
353            // stages render through the caller's Ui in install_inner.
354            match self.install_inner(&r, true, ui, None) {
355                Ok(done) => out.push(SweepOutcome::Upgraded {
356                    name: receipt.name,
357                    from: receipt.tag,
358                    to: done.tag,
359                }),
360                Err(e) => out.push(SweepOutcome::Failed {
361                    name: receipt.name,
362                    error: e.to_string(),
363                }),
364            }
365        }
366        out
367    }
368}
369
370/// Only releases-API sources (`owner/repo` on github.com) are tracked
371/// by `update`/`upgrade` — plain-HTTP URLs and `--path` installs are
372/// install-and-pin (plans/0014 #1b).
373fn tracks_releases(source: &str) -> bool {
374    !source.contains("://") && source.matches('/').count() == 1
375}
376
377#[cfg(test)]
378mod tests;