diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2fd2e58..2061395 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -53,7 +53,7 @@ pub enum McpScopeArg { "install_from", "sync_models", "list_models", "list_roles", "list_sessions", "list_agents", "list_rags", "list_macros", "list_skills", "list_bundles", "skill", "tail_logs", "completions", - "update", + "update", "update_bundle", ]) ), group( @@ -207,6 +207,9 @@ pub struct Cli { help_heading = "Installation & Updates" )] pub install_force: bool, + /// Update an installed bundle from its recorded source (NAME may be suffixed with # to move a pin) + #[arg(long, value_name = "NAME", help_heading = "Installation & Updates")] + pub update_bundle: Option, /// Sync models updates #[arg(long, help_heading = "Installation & Updates")] pub sync_models: bool, @@ -508,6 +511,14 @@ mod tests { assert!(parse(&[]).skill.is_empty()); } + #[test] + fn parse_update_bundle_flag_takes_name() { + assert_eq!( + parse(&["--update-bundle", "foo"]).update_bundle.as_deref(), + Some("foo") + ); + } + #[test] fn parse_multiple_skill_flags_preserves_order() { assert_eq!( diff --git a/src/config/bundles.rs b/src/config/bundles.rs index 5be258e..54eaad2 100644 --- a/src/config/bundles.rs +++ b/src/config/bundles.rs @@ -65,6 +65,8 @@ pub(crate) struct BundleRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) homepage: Option, pub(crate) installed_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, #[serde(default)] pub(crate) files: Vec, #[serde(default)] @@ -153,7 +155,6 @@ impl BundleStore { .with_context(|| format!("failed to write {}", self.path.display())) } - #[allow(dead_code)] pub(crate) fn get(&self, name: &str) -> Option<&BundleRecord> { self.bundles.get(name) } @@ -308,6 +309,7 @@ impl BundleStore { description: metadata.description, homepage: metadata.homepage, installed_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + updated_at: None, files: Vec::new(), mcp_servers: Vec::new(), }, @@ -317,6 +319,29 @@ impl BundleStore { self.save() } + /// Stamp the record with the time of its most recent update from source. + pub(crate) fn mark_updated(&mut self, name: &str) -> Result<()> { + self.ensure_bundle_exists(name)?; + let record = self + .bundles + .get_mut(name) + .expect("bundle existence checked above"); + record.updated_at = Some(Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)); + self.save() + } + + /// Drop one path from a bundle's owned files and persist. Used when the + /// bundle no longer ships the file and it is gone (or deleted) locally. + pub(crate) fn remove_file_record(&mut self, bundle: &str, path: &str) -> Result<()> { + self.ensure_bundle_exists(bundle)?; + let record = self + .bundles + .get_mut(bundle) + .expect("bundle existence checked above"); + record.files.retain(|owned| owned.path != path); + self.save() + } + /// Record one written file and persist immediately, so an install aborted /// partway through still has provenance for everything already on disk. /// A path owned by another bundle transfers to `bundle`. @@ -967,6 +992,72 @@ mod tests { assert!(result.unwrap_err().to_string().contains("ghost")); } + #[test] + fn mark_updated_stamps_and_round_trips() { + let dir = TempStoreDir::new("bundles-mark-updated"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + let raw_before = fs::read_to_string(dir.store_path()).unwrap(); + assert!(!raw_before.contains("updated_at"), "{raw_before}"); + assert_eq!(store.get("omc").unwrap().updated_at, None); + + store.mark_updated("omc").unwrap(); + + let raw_after = fs::read_to_string(dir.store_path()).unwrap(); + assert!(raw_after.contains("updated_at"), "{raw_after}"); + let reloaded = dir.store(); + assert!(reloaded.get("omc").unwrap().updated_at.is_some()); + } + + #[test] + fn mark_updated_unknown_bundle_fails() { + let dir = TempStoreDir::new("bundles-mark-unknown"); + let mut store = dir.store(); + + let result = store.mark_updated("ghost"); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + + #[test] + fn remove_file_record_drops_only_the_named_path() { + let dir = TempStoreDir::new("bundles-remove-file"); + let mut store = dir.store(); + store + .upsert_bundle("omc", metadata("https://github.com/x/omc", "abc123")) + .unwrap(); + store + .record_file("omc", file_record("macros/a.yaml", "a")) + .unwrap(); + store + .record_file("omc", file_record("macros/b.yaml", "b")) + .unwrap(); + + store.remove_file_record("omc", "macros/a.yaml").unwrap(); + + let reloaded = dir.store(); + let paths: Vec<&str> = reloaded + .get("omc") + .unwrap() + .files + .iter() + .map(|f| f.path.as_str()) + .collect(); + assert_eq!(paths, vec!["macros/b.yaml"]); + } + + #[test] + fn remove_file_record_unknown_bundle_fails() { + let dir = TempStoreDir::new("bundles-remove-unknown"); + let mut store = dir.store(); + + let result = store.remove_file_record("ghost", "macros/a.yaml"); + + assert!(result.unwrap_err().to_string().contains("ghost")); + } + #[cfg(unix)] #[test] fn failed_save_preserves_the_existing_store() { diff --git a/src/config/install_remote.rs b/src/config/install_remote.rs index 913ffd5..6ccdd39 100644 --- a/src/config/install_remote.rs +++ b/src/config/install_remote.rs @@ -13,6 +13,7 @@ use indexmap::IndexMap; use indoc::formatdoc; use inquire::{Confirm, Select}; use serde::Deserialize; +use std::collections::{HashMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; @@ -103,6 +104,208 @@ pub fn install_remote_from_repl_args(args: &str) -> Result<()> { install_remote(&url, filter, force) } +/// Update an installed bundle from its recorded source. `spec` is the bundle +/// name, optionally suffixed with `#` to move a pinned ref. The whole +/// remote is always processed — including categories a filtered install +/// excluded — because filtered installs merge into a single record and an +/// update brings that record in line with everything the remote now ships. +pub fn update_bundle(spec: &str) -> Result<()> { + let (name, ref_override) = parse_url_with_ref(spec)?; + + let mut store = BundleStore::load()?; + let Some(record) = store.get(&name) else { + let installed = store.bundle_names(); + if installed.is_empty() { + bail!("no bundle named '{name}' is installed; none are installed"); + } + bail!( + "no bundle named '{name}' is installed; installed bundles: {}", + installed.join(", ") + ); + }; + let source = record.source.clone(); + let recorded_ref = record.git_ref.clone(); + + let has_override = ref_override.is_some(); + let effective_ref = ref_override.or(recorded_ref); + if !has_override + && let Some(pinned) = effective_ref.as_deref() + && is_commit_sha(pinned) + { + println!("Bundle '{name}' is pinned to commit {pinned}; pass # to move the pin."); + } + + let temp = clone_to_temp(&source, effective_ref.as_deref())?; + println!("Cloned {source} to {}", temp.path().display()); + + let mut layout = scan_remote_layout(temp.path())?; + layout.head_sha = Some(temp.head_sha().to_string()); + if layout.is_empty() { + println!( + "The source for '{name}' no longer contains recognized assets; \ + leaving installed files and the bundle record untouched." + ); + return Ok(()); + } + + let bundle = register_bundle( + &mut store, + &source, + effective_ref.as_deref(), + layout.manifest.as_ref(), + temp.head_sha(), + )?; + + let plan = plan_changes(&layout)?; + let plan = reclassify_owned_unmodified(plan, &store, &bundle)?; + + if !plan.files.is_empty() { + print_plan_summary(&plan); + apply_plan(&plan, StickyMode::None, &mut store, &bundle)?; + } + + handle_obsolete_files(&mut store, &bundle, &plan)?; + + if let Some((remote_mcp, local_mcp)) = &plan.mcp_json { + let local = local_mcp.exists().then_some(local_mcp.as_path()); + let report = merge_mcp_json(local, remote_mcp, local_mcp, false)?; + record_mcp_merge(&mut store, &bundle, &report)?; + print_mcp_merge_report(&report); + handle_missing_secrets(&report.missing_secrets)?; + } + + store.mark_updated(&bundle)?; + + Ok(()) +} + +/// A conflict on a file this bundle owns whose on-disk content still matches +/// the recorded hash is not a real conflict: the bundle wrote that content and +/// the user never touched it, so an update refreshes it without prompting. +/// Files the user modified — or that another bundle owns — keep the normal +/// conflict semantics. +fn reclassify_owned_unmodified( + mut plan: InstallPlan, + store: &BundleStore, + bundle: &str, +) -> Result { + let owned: HashMap<&str, &str> = store + .get(bundle) + .map(|record| { + record + .files + .iter() + .map(|file| (file.path.as_str(), file.sha256.as_str())) + .collect() + }) + .unwrap_or_default(); + + for planned in &mut plan.files { + if planned.kind != PlannedKind::Conflict { + continue; + } + let Some(recorded) = owned.get(provenance_path(&planned.dst).as_str()) else { + continue; + }; + if hash_file(&planned.dst)? == *recorded { + planned.kind = PlannedKind::Refresh; + } + } + Ok(plan) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObsoleteAction { + Keep, + Delete, +} + +/// Reconcile owned files the remote no longer ships. A file already gone from +/// disk just drops out of the record; a file still present is kept by default +/// (the user may rely on it) and only deleted on explicit confirmation. Kept +/// files stay in the record, so a later uninstall still offers to remove them. +fn handle_obsolete_files(store: &mut BundleStore, bundle: &str, plan: &InstallPlan) -> Result<()> { + let planned: HashSet = plan + .files + .iter() + .map(|planned| provenance_path(&planned.dst)) + .collect(); + let obsolete: Vec = store + .get(bundle) + .map(|record| { + record + .files + .iter() + .map(|file| file.path.clone()) + .filter(|path| !planned.contains(path)) + .collect() + }) + .unwrap_or_default(); + + let config_dir = paths::config_dir(); + let mut sticky: Option = None; + for path in obsolete { + let full = config_dir.join(&path); + if !full.exists() { + println!("dropped record for obsolete file {path} (already absent locally)"); + store.remove_file_record(bundle, &path)?; + continue; + } + let action = resolve_obsolete(&path, &mut sticky)?; + apply_obsolete_action(store, bundle, &path, &full, action)?; + } + Ok(()) +} + +fn resolve_obsolete(path: &str, sticky: &mut Option) -> Result { + if let Some(action) = *sticky { + return Ok(action); + } + if !*IS_STDOUT_TERMINAL { + return Ok(ObsoleteAction::Keep); + } + + let prompt = format!("Obsolete file {path} is no longer shipped by the bundle"); + let choice = Select::new(&prompt, vec!["keep", "delete", "keep-all", "delete-all"]) + .prompt() + .with_context(|| "failed to read obsolete-file choice")?; + + match choice { + "keep" => Ok(ObsoleteAction::Keep), + "delete" => Ok(ObsoleteAction::Delete), + "keep-all" => { + *sticky = Some(ObsoleteAction::Keep); + Ok(ObsoleteAction::Keep) + } + "delete-all" => { + *sticky = Some(ObsoleteAction::Delete); + Ok(ObsoleteAction::Delete) + } + _ => unreachable!("inquire::Select returned an unexpected option"), + } +} + +fn apply_obsolete_action( + store: &mut BundleStore, + bundle: &str, + path: &str, + full: &Path, + action: ObsoleteAction, +) -> Result<()> { + match action { + ObsoleteAction::Keep => { + println!("kept obsolete file {path} (no longer shipped by the bundle)"); + } + ObsoleteAction::Delete => { + fs::remove_file(full) + .with_context(|| format!("failed to delete obsolete file {}", full.display()))?; + store.remove_file_record(bundle, path)?; + println!("deleted obsolete file {path}"); + } + } + Ok(()) +} + fn parse_filter(name: &str) -> Result { InstallFilter::parse(name).with_context(|| { format!( @@ -160,13 +363,17 @@ impl Drop for TempRepoDir { } } +fn is_commit_sha(reference: &str) -> bool { + reference.len() >= 4 + && reference.len() <= 40 + && reference.chars().all(|c| c.is_ascii_hexdigit()) +} + fn clone_to_temp(url: &str, reference: Option<&str>) -> Result { let dest = utils::temp_file("coyote-remote-install-", ""); let dest_arg: OsString = dest.as_os_str().into(); - let is_sha = reference - .map(|r| r.len() >= 4 && r.len() <= 40 && r.chars().all(|c| c.is_ascii_hexdigit())) - .unwrap_or(false); + let is_sha = reference.is_some_and(is_commit_sha); match reference { Some(r) if !is_sha => { @@ -567,6 +774,9 @@ enum PlannedKind { New, Identical, Conflict, + /// A conflict downgraded because this bundle owns the file and the local + /// content still matches the recorded hash; applied without prompting. + Refresh, } struct PlannedFile { @@ -733,11 +943,16 @@ fn print_plan_summary(plan: &InstallPlan) { let new_ = count_kind(plan, cat, PlannedKind::New); let identical = count_kind(plan, cat, PlannedKind::Identical); let conflict = count_kind(plan, cat, PlannedKind::Conflict); - if new_ + identical + conflict > 0 { - println!( + let refresh = count_kind(plan, cat, PlannedKind::Refresh); + if new_ + identical + conflict + refresh > 0 { + let mut line = format!( " {:<16} new={new_} identical={identical} conflict={conflict}", cat.label() ); + if refresh > 0 { + line.push_str(&format!(" refresh={refresh}")); + } + println!("{line}"); } } } @@ -766,6 +981,7 @@ struct ApplyReport { new_count: usize, identical_count: usize, replaced_count: usize, + refreshed_count: usize, kept_count: usize, } @@ -779,6 +995,7 @@ fn apply_plan( new_count: 0, identical_count: 0, replaced_count: 0, + refreshed_count: 0, kept_count: 0, }; let mut sticky = initial_mode; @@ -793,6 +1010,11 @@ fn apply_plan( PlannedKind::Identical => { report.identical_count += 1; } + PlannedKind::Refresh => { + write_file(&planned.src, &planned.dst)?; + record_written_file(store, bundle, planned, FileAction::Replaced)?; + report.refreshed_count += 1; + } PlannedKind::Conflict => match resolve_conflict(planned, &mut sticky)? { ConflictAction::Keep => report.kept_count += 1, ConflictAction::Replace => { @@ -804,10 +1026,21 @@ fn apply_plan( } } - println!( - "\nInstalled: {} new, {} replaced, {} kept, {} identical.", - report.new_count, report.replaced_count, report.kept_count, report.identical_count - ); + if report.refreshed_count > 0 { + println!( + "\nInstalled: {} new, {} refreshed, {} replaced, {} kept, {} identical.", + report.new_count, + report.refreshed_count, + report.replaced_count, + report.kept_count, + report.identical_count + ); + } else { + println!( + "\nInstalled: {} new, {} replaced, {} kept, {} identical.", + report.new_count, report.replaced_count, report.kept_count, report.identical_count + ); + } Ok(report) } @@ -2442,4 +2675,415 @@ mod tests { assert!(fs::read_dir(paths::macros_dir()).unwrap().next().is_some()); assert!(!paths::installed_bundles_file().exists()); } + + #[test] + #[serial] + fn update_silently_refreshes_owned_unmodified_files() { + use crate::config::bundles::hash_bytes; + + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_silently_refreshes_owned_unmodified_files: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-refresh"); + let src_root = fresh_temp_dir("upd-refresh-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: refresh-bundle\n"); + write_src(&repo, "macros/hello.yaml", "v1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + commit_file(&repo, "macros/hello.yaml", "v2\n"); + + update_bundle("refresh-bundle").unwrap(); + + let installed = paths::macros_dir().join("hello.yaml"); + assert_eq!(fs::read_to_string(&installed).unwrap(), "v2\n"); + let store = BundleStore::load().unwrap(); + let files = &store.get("refresh-bundle").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].sha256, hash_bytes(b"v2\n")); + assert_eq!(files[0].action, FileAction::Replaced); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn apply_plan_keep_all_preserves_local_edit_and_stale_record() { + let dir = fresh_temp_dir("upd-keep-modified-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/owned.yaml"); + write_src(&dir, "macros/owned.yaml", "installed content"); + let recorded_sha = hash_file(&dst).unwrap(); + store + .record_file( + "omc", + FileRecord { + path: provenance_path(&dst), + category: "macros".to_string(), + sha256: recorded_sha.clone(), + action: FileAction::New, + }, + ) + .unwrap(); + fs::write(&dst, "local edit").unwrap(); + let src = dir.join("upstream/owned.yaml"); + write_src(&dir, "upstream/owned.yaml", "upstream content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::KeepAll, &mut store, "omc").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "local edit"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + let files = &reloaded.get("omc").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].sha256, recorded_sha); + assert_ne!(files[0].sha256, hash_file(&dst).unwrap()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn apply_plan_replace_all_takes_upstream_and_rerecords_hash() { + use crate::config::bundles::hash_bytes; + + let dir = fresh_temp_dir("upd-replace-modified-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/owned.yaml"); + write_src(&dir, "macros/owned.yaml", "installed content"); + store + .record_file( + "omc", + FileRecord { + path: provenance_path(&dst), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + fs::write(&dst, "local edit").unwrap(); + let src = dir.join("upstream/owned.yaml"); + write_src(&dir, "upstream/owned.yaml", "upstream content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "omc").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "upstream content"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + let files = &reloaded.get("omc").unwrap().files; + assert_eq!(files.len(), 1); + assert_eq!(files[0].sha256, hash_bytes(b"upstream content")); + assert_eq!(files[0].action, FileAction::Replaced); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn update_bails_non_interactively_on_unowned_conflict() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_bails_non_interactively_on_unowned_conflict: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-unowned"); + let src_root = fresh_temp_dir("upd-unowned-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: unowned-bundle\n"); + write_src(&repo, "macros/a.yaml", "a\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + commit_file(&repo, "macros/user.yaml", "upstream\n"); + fs::create_dir_all(paths::macros_dir()).unwrap(); + fs::write(paths::macros_dir().join("user.yaml"), "local\n").unwrap(); + + let err = update_bundle("unowned-bundle").unwrap_err(); + + assert!( + err.to_string().contains("Refusing to overwrite"), + "got: {err}" + ); + assert_eq!( + fs::read_to_string(paths::macros_dir().join("user.yaml")).unwrap(), + "local\n" + ); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn apply_plan_replace_all_transfers_ownership_between_bundles() { + let dir = fresh_temp_dir("upd-transfer-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("alpha", test_metadata("https://github.com/a/alpha")) + .unwrap(); + store + .upsert_bundle("beta", test_metadata("https://github.com/b/beta")) + .unwrap(); + let dst = dir.join("macros/shared.yaml"); + write_src(&dir, "macros/shared.yaml", "alpha content"); + store + .record_file( + "alpha", + FileRecord { + path: provenance_path(&dst), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + let src = dir.join("beta-src/shared.yaml"); + write_src(&dir, "beta-src/shared.yaml", "beta content"); + let plan = InstallPlan { + files: vec![PlannedFile { + src, + dst: dst.clone(), + kind: PlannedKind::Conflict, + top_category: TopCategory::Macros, + }], + mcp_json: None, + }; + + apply_plan(&plan, StickyMode::ReplaceAll, &mut store, "beta").unwrap(); + + assert_eq!(fs::read_to_string(&dst).unwrap(), "beta content"); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + assert!(reloaded.get("alpha").unwrap().files.is_empty()); + let beta_files = &reloaded.get("beta").unwrap().files; + assert_eq!(beta_files.len(), 1); + assert_eq!(beta_files[0].path, provenance_path(&dst)); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn update_keeps_obsolete_files_non_interactively() { + if *IS_STDOUT_TERMINAL { + eprintln!( + "Skipping update_keeps_obsolete_files_non_interactively: requires non-TTY stdout" + ); + return; + } + let _guard = TestVaultConfigGuard::new("upd-obsolete-keep"); + let src_root = fresh_temp_dir("upd-obsolete-keep-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: obs-keep\n"); + write_src(&repo, "macros/keep.yaml", "k\n"); + write_src(&repo, "macros/gone.yaml", "g\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + fs::remove_file(repo.join("macros/gone.yaml")).unwrap(); + commit_file(&repo, "macros/keep.yaml", "k2\n"); + + update_bundle("obs-keep").unwrap(); + + assert_eq!( + fs::read_to_string(paths::macros_dir().join("gone.yaml")).unwrap(), + "g\n" + ); + let store = BundleStore::load().unwrap(); + let record = store.get("obs-keep").unwrap(); + assert!(record.files.iter().any(|f| f.path == "macros/gone.yaml")); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + fn apply_obsolete_delete_removes_file_and_record() { + let dir = fresh_temp_dir("upd-obsolete-delete-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let dst = dir.join("macros/gone.yaml"); + write_src(&dir, "macros/gone.yaml", "g"); + let path = provenance_path(&dst); + store + .record_file( + "omc", + FileRecord { + path: path.clone(), + category: "macros".to_string(), + sha256: hash_file(&dst).unwrap(), + action: FileAction::New, + }, + ) + .unwrap(); + + apply_obsolete_action(&mut store, "omc", &path, &dst, ObsoleteAction::Delete).unwrap(); + + assert!(!dst.exists()); + let reloaded = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + assert!(reloaded.get("omc").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn handle_obsolete_drops_records_for_missing_files() { + let dir = fresh_temp_dir("upd-obsolete-missing-"); + let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap(); + store + .upsert_bundle("omc", test_metadata("https://github.com/x/omc")) + .unwrap(); + let ghost = dir.join("macros/ghost.yaml"); + store + .record_file( + "omc", + FileRecord { + path: provenance_path(&ghost), + category: "macros".to_string(), + sha256: "0".repeat(64), + action: FileAction::New, + }, + ) + .unwrap(); + let plan = InstallPlan { + files: Vec::new(), + mcp_json: None, + }; + + handle_obsolete_files(&mut store, "omc", &plan).unwrap(); + + assert!(store.get("omc").unwrap().files.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + #[serial] + fn update_refreshes_record_metadata_and_stamps_updated_at() { + let _guard = TestVaultConfigGuard::new("upd-meta"); + let src_root = fresh_temp_dir("upd-meta-src-"); + let repo = src_root.join("bundle"); + write_src( + &repo, + BUNDLE_MANIFEST_FILE, + "name: meta-bundle\n\ + version: \"1.0\"\n\ + description: Old\n\ + homepage: https://example.com/old\n", + ); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + let installed_at = BundleStore::load() + .unwrap() + .get("meta-bundle") + .unwrap() + .installed_at + .clone(); + let new_sha = commit_file( + &repo, + BUNDLE_MANIFEST_FILE, + "name: meta-bundle\n\ + version: \"2.0\"\n\ + description: New\n\ + homepage: https://example.com/new\n", + ); + + update_bundle("meta-bundle").unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("meta-bundle").unwrap(); + assert_eq!(record.commit, new_sha); + assert_eq!(record.version.as_deref(), Some("2.0")); + assert_eq!(record.description.as_deref(), Some("New")); + assert_eq!(record.homepage.as_deref(), Some("https://example.com/new")); + assert!(record.updated_at.is_some()); + assert_eq!(record.installed_at, installed_at); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_unknown_bundle_lists_installed_names() { + let _guard = TestVaultConfigGuard::new("upd-unknown-name"); + + let err = update_bundle("nope").unwrap_err(); + assert!(err.to_string().contains("none are installed"), "got: {err}"); + + let src_root = fresh_temp_dir("upd-unknown-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: known-bundle\n"); + write_src(&repo, "macros/m.yaml", "a: 1\n"); + init_bundle_repo(&repo); + install_remote(repo.to_str().unwrap(), None, false).unwrap(); + + let err = update_bundle("nope").unwrap_err(); + + assert!( + err.to_string().contains("installed bundles: known-bundle"), + "got: {err}" + ); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_honors_recorded_sha_pin() { + let _guard = TestVaultConfigGuard::new("upd-pin"); + let src_root = fresh_temp_dir("upd-pin-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: pin-bundle\n"); + write_src(&repo, "macros/one.yaml", "1\n"); + let pinned = init_bundle_repo(&repo); + install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); + let newer = commit_file(&repo, "macros/two.yaml", "2\n"); + assert_ne!(pinned, newer); + + update_bundle("pin-bundle").unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("pin-bundle").unwrap(); + assert_eq!(record.commit, pinned); + assert_eq!(record.git_ref.as_deref(), Some(pinned.as_str())); + assert!(!paths::macros_dir().join("two.yaml").exists()); + let _ = fs::remove_dir_all(&src_root); + } + + #[test] + #[serial] + fn update_ref_override_moves_the_pin() { + let _guard = TestVaultConfigGuard::new("upd-move-pin"); + let src_root = fresh_temp_dir("upd-move-pin-src-"); + let repo = src_root.join("bundle"); + write_src(&repo, BUNDLE_MANIFEST_FILE, "name: move-bundle\n"); + write_src(&repo, "macros/one.yaml", "1\n"); + let pinned = init_bundle_repo(&repo); + install_remote(&format!("{}#{pinned}", repo.display()), None, false).unwrap(); + let newer = commit_file(&repo, "macros/two.yaml", "2\n"); + + update_bundle(&format!("move-bundle#{newer}")).unwrap(); + + let store = BundleStore::load().unwrap(); + let record = store.get("move-bundle").unwrap(); + assert_eq!(record.commit, newer); + assert_eq!(record.git_ref.as_deref(), Some(newer.as_str())); + assert_eq!( + fs::read_to_string(paths::macros_dir().join("two.yaml")).unwrap(), + "2\n" + ); + let _ = fs::remove_dir_all(&src_root); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index c13c297..21c8e96 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -32,7 +32,7 @@ pub use self::app_config::AppConfig; pub use self::app_state::AppState; pub use self::bundles::list_installed_bundles; pub use self::input::Input; -pub use self::install_remote::{install_remote, install_remote_from_repl_args}; +pub use self::install_remote::{install_remote, install_remote_from_repl_args, update_bundle}; pub use self::macro_policy::{ MacroAllowlistLevel, MacroPolicy, MacroSource, MacroState, RESERVED_MACRO_NAMES, ResolvedMacro, }; diff --git a/src/main.rs b/src/main.rs index 4ecc548..d95dd75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,6 +138,10 @@ async fn main() -> Result<()> { return config::install_remote(url, cli.filter, cli.install_force); } + if let Some(spec) = cli.update_bundle.as_deref() { + return config::update_bundle(spec); + } + if let Some(client_arg) = &cli.authenticate { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?;