Skip to main content

registry_cli/
ops.rs

1//! Install / update / uninstall operations over the registry client and
2//! lockfile.
3
4use std::path::{Component, Path, PathBuf};
5
6use sha2::{Digest, Sha256};
7
8use crate::client::{Manifest, RegistryClient};
9use crate::lockfile::{LockEntry, Lockfile};
10use crate::{CliError, Result, parse_handle};
11
12/// Outcome of an install.
13#[derive(Debug)]
14pub struct InstallSummary {
15    pub handle: String,
16    pub target: String,
17    pub version: String,
18    pub written: Vec<String>,
19    /// True when the artifact was already installed at this version (no writes).
20    pub skipped: bool,
21    /// True when the manifest is not pinned to an immutable commit sha.
22    pub unpinned: bool,
23}
24
25/// Install `handle` for `target` under `dir`, recording the result in the
26/// lockfile at `lock_path`. Refuses to overwrite pre-existing files unless
27/// `force`; a re-install at the same version is a no-op.
28pub async fn install(
29    client: &RegistryClient,
30    handle: &str,
31    target: &str,
32    dir: &Path,
33    force: bool,
34    lock_path: &Path,
35) -> Result<InstallSummary> {
36    let (ns, slug) = parse_handle(handle)?;
37    let manifest = client.manifest(&ns, &slug, target).await?;
38    let mut lock = Lockfile::load(lock_path)?;
39
40    let dests: Vec<PathBuf> = manifest
41        .files
42        .iter()
43        .map(|f| safe_dest(dir, &f.dest_path))
44        .collect::<Result<_>>()?;
45    let all_exist = !dests.is_empty() && dests.iter().all(|p| p.exists());
46    let up_to_date = lock
47        .get(&manifest.handle, target)
48        .is_some_and(|e| e.version == manifest.version)
49        && all_exist;
50
51    if up_to_date && !force {
52        return Ok(InstallSummary {
53            handle: manifest.handle,
54            target: target.to_string(),
55            version: manifest.version,
56            written: Vec::new(),
57            skipped: true,
58            unpinned: !manifest.pinned,
59        });
60    }
61
62    if !force {
63        let conflicts: Vec<String> = dests
64            .iter()
65            .filter(|p| p.exists())
66            .map(|p| p.display().to_string())
67            .collect();
68        if !conflicts.is_empty() {
69            return Err(CliError::WouldOverwrite(conflicts));
70        }
71    }
72
73    let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
74    lock.upsert(LockEntry {
75        handle: manifest.handle.clone(),
76        version: manifest.version.clone(),
77        target: target.to_string(),
78        content_hash,
79        files: written.clone(),
80    });
81    lock.save(lock_path)?;
82
83    Ok(InstallSummary {
84        handle: manifest.handle,
85        target: target.to_string(),
86        version: manifest.version,
87        written,
88        skipped: false,
89        unpinned: !manifest.pinned,
90    })
91}
92
93/// A single pending/applied update.
94#[derive(Debug)]
95pub struct UpdateChange {
96    pub handle: String,
97    pub target: String,
98    pub from: String,
99    pub to: String,
100}
101
102/// An update that was available but not written.
103#[derive(Debug)]
104pub struct UpdateSkip {
105    pub handle: String,
106    pub target: String,
107    pub reason: String,
108}
109
110/// Result of an update run.
111#[derive(Debug)]
112pub struct UpdateReport {
113    pub changes: Vec<UpdateChange>,
114    /// Entries whose new version was not written because the installed files
115    /// had drifted from the lockfile.
116    pub skipped: Vec<UpdateSkip>,
117    pub applied: bool,
118}
119
120/// Re-resolve every lockfile entry. With `apply`, download and rewrite changed
121/// artifacts (overwriting their own files) and update the lockfile.
122///
123/// An artifact whose installed files no longer match the lockfile is reported
124/// and left alone unless `force`. This matters most for the targets that write
125/// to a fixed project path — `codex` owns `AGENTS.md` and `copilot` owns
126/// `.github/copilot-instructions.md` — where an overwrite would take a file the
127/// project also edits by hand.
128pub async fn update(
129    client: &RegistryClient,
130    dir: &Path,
131    lock_path: &Path,
132    apply: bool,
133    force: bool,
134) -> Result<UpdateReport> {
135    let mut lock = Lockfile::load(lock_path)?;
136    let entries = lock.artifacts.clone();
137    let mut changes = Vec::new();
138    let mut skipped = Vec::new();
139    let mut wrote_any = false;
140
141    for entry in &entries {
142        let (ns, slug) = parse_handle(&entry.handle)?;
143        let manifest = client.manifest(&ns, &slug, &entry.target).await?;
144        if manifest.version == entry.version {
145            continue;
146        }
147        changes.push(UpdateChange {
148            handle: entry.handle.clone(),
149            target: entry.target.clone(),
150            from: entry.version.clone(),
151            to: manifest.version.clone(),
152        });
153        if !apply {
154            continue;
155        }
156        if !force {
157            let paths: Vec<(String, PathBuf)> = entry
158                .files
159                .iter()
160                .map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
161                .collect::<Result<_>>()?;
162            match verify_unmodified(entry, &paths) {
163                Ok(()) => {}
164                Err(CliError::LocallyModified { details, .. }) => {
165                    skipped.push(UpdateSkip {
166                        handle: entry.handle.clone(),
167                        target: entry.target.clone(),
168                        reason: details.join("; ").trim().to_string(),
169                    });
170                    continue;
171                }
172                Err(e) => return Err(e),
173            }
174        }
175        let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
176        lock.upsert(LockEntry {
177            handle: manifest.handle.clone(),
178            version: manifest.version.clone(),
179            target: entry.target.clone(),
180            content_hash,
181            files: written,
182        });
183        wrote_any = true;
184    }
185
186    if wrote_any {
187        lock.save(lock_path)?;
188    }
189
190    Ok(UpdateReport {
191        changes,
192        skipped,
193        applied: apply,
194    })
195}
196
197/// Outcome of an uninstall.
198#[derive(Debug)]
199pub struct UninstallSummary {
200    pub handle: String,
201    pub target: String,
202    pub version: String,
203    /// Files deleted from disk.
204    pub removed: Vec<String>,
205    /// Files the lockfile recorded that were already gone.
206    pub missing: Vec<String>,
207}
208
209/// Remove an installed artifact's files and its lockfile entry.
210///
211/// Offline: `agr.lock` records everything needed, so removal never contacts the
212/// registry — an artifact stays uninstallable after it is unpublished.
213///
214/// Only the paths the lockfile recorded are touched, never a glob of the
215/// install directory. Without `force` the install must still be byte-identical
216/// to what was written, so local edits are never silently discarded.
217pub fn uninstall(
218    dir: &Path,
219    lock_path: &Path,
220    handle: &str,
221    target: &str,
222    force: bool,
223) -> Result<UninstallSummary> {
224    let (ns, slug) = parse_handle(handle)?;
225    let handle = format!("@{ns}/{slug}");
226    let mut lock = Lockfile::load(lock_path)?;
227    let entry = lock
228        .get(&handle, target)
229        .ok_or_else(|| CliError::NotInstalled {
230            handle: handle.clone(),
231            target: target.to_string(),
232        })?
233        .clone();
234
235    let paths: Vec<(String, PathBuf)> = entry
236        .files
237        .iter()
238        .map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
239        .collect::<Result<_>>()?;
240
241    if !force {
242        verify_unmodified(&entry, &paths)?;
243    }
244
245    let mut removed = Vec::new();
246    let mut missing = Vec::new();
247    for (rel, path) in &paths {
248        match std::fs::remove_file(path) {
249            Ok(()) => removed.push(rel.clone()),
250            Err(e) if e.kind() == std::io::ErrorKind::NotFound => missing.push(rel.clone()),
251            Err(e) => return Err(e.into()),
252        }
253        prune_empty_dirs(dir, path);
254    }
255
256    lock.remove(&handle, target);
257    lock.save(lock_path)?;
258
259    Ok(UninstallSummary {
260        handle,
261        target: target.to_string(),
262        version: entry.version,
263        removed,
264        missing,
265    })
266}
267
268/// Refuse to delete an install that no longer matches what was recorded: every
269/// recorded file must still be present, and the digest over them must equal the
270/// lockfile's.
271fn verify_unmodified(entry: &LockEntry, paths: &[(String, PathBuf)]) -> Result<()> {
272    let mut hasher = Sha256::new();
273    let mut details = Vec::new();
274    for (rel, path) in paths {
275        match std::fs::read(path) {
276            Ok(bytes) => hasher.update(&bytes),
277            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
278                details.push(format!("  {rel} (already deleted)"));
279            }
280            Err(e) => return Err(e.into()),
281        }
282    }
283    if details.is_empty() {
284        if hex::encode(hasher.finalize()) == entry.content_hash {
285            return Ok(());
286        }
287        details.push("  contents differ from the installed version".to_string());
288    }
289    Err(CliError::LocallyModified {
290        handle: entry.handle.clone(),
291        details,
292    })
293}
294
295/// Remove directories left empty by a deletion, walking up toward — but never
296/// past or including — the install root. `remove_dir` fails on a non-empty
297/// directory, which is what stops the walk at the first directory holding
298/// anything else.
299fn prune_empty_dirs(root: &Path, file: &Path) {
300    let mut current = file.parent();
301    while let Some(dir) = current {
302        if dir == root || !dir.starts_with(root) || std::fs::remove_dir(dir).is_err() {
303            return;
304        }
305        current = dir.parent();
306    }
307}
308
309/// Resolve a manifest destination under `dir`, rejecting anything that escapes
310/// it. Destination paths arrive from the registry, so a hostile or malformed
311/// manifest must not be able to reach outside the project — on install to
312/// write, and on uninstall to delete.
313fn safe_dest(dir: &Path, rel: &str) -> Result<PathBuf> {
314    let path = Path::new(rel);
315    let escapes = path.components().any(|c| {
316        matches!(
317            c,
318            Component::ParentDir | Component::RootDir | Component::Prefix(_)
319        )
320    });
321    if rel.is_empty() || escapes {
322        return Err(CliError::UnsafePath(rel.to_string()));
323    }
324    Ok(dir.join(path))
325}
326
327/// Download every file in `manifest` and write it under `dir`, returning the
328/// written destination paths and a sha256 over their concatenated bytes.
329///
330/// Files are processed in destination order so the digest can be recomputed
331/// from the lockfile's (sorted) `files` alone — which is how `uninstall` tells
332/// an untouched install from an edited one.
333async fn apply_manifest(
334    client: &RegistryClient,
335    manifest: &Manifest,
336    dir: &Path,
337) -> Result<(Vec<String>, String)> {
338    let mut files: Vec<&crate::client::ManifestFile> = manifest.files.iter().collect();
339    files.sort_by(|a, b| a.dest_path.cmp(&b.dest_path));
340
341    let mut hasher = Sha256::new();
342    let mut written = Vec::new();
343    for file in files {
344        let path = safe_dest(dir, &file.dest_path)?;
345        let bytes = client.fetch_file(&file.source_url).await?;
346        hasher.update(&bytes);
347        if let Some(parent) = path.parent() {
348            std::fs::create_dir_all(parent)?;
349        }
350        std::fs::write(&path, &bytes)?;
351        written.push(file.dest_path.clone());
352    }
353    Ok((written, hex::encode(hasher.finalize())))
354}