From 057a7defe5c056de2a73d221bc91de1c8b1071ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 14:12:46 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli/org):=20org=20edit=20=E2=80=94=20flag-?= =?UTF-8?q?driven=20field=20update=20for=20login/note/identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/relicario-cli/src/commands/org.rs | 71 ++++++++++++++++++++++++ crates/relicario-cli/src/main.rs | 25 +++++++-- crates/relicario-cli/tests/org_items.rs | 30 ++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 6962e00..4ae1561 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -973,6 +973,77 @@ fn resolve_org_query<'a>( } } +pub fn run_edit( + dir: &Path, + query: &str, + title: Option, + username: Option, + url: Option, + password: Option, + body: Option, + email: Option, + phone: Option, + full_name: Option, +) -> Result<()> { + use relicario_core::time::now_unix; + use relicario_core::ItemCore; + use zeroize::Zeroizing; + + let vault = crate::org_session::open_org_vault(Some(dir))?; + let caller = vault.current_member()?; + let manifest = vault.load_manifest()?; + let visible = manifest.filter_for_member(&caller); + let entry = resolve_org_query(&visible, query)?; + let collection = entry.collection.clone(); + let id = entry.id.clone(); + crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; + + let mut item = vault.load_item(&collection, &id)?; + + if let Some(t) = title { item.title = t; } + + match &mut item.core { + ItemCore::Login(l) => { + if let Some(u) = username { l.username = Some(u); } + if let Some(u) = url { + l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); + } + if let Some(p) = password { l.password = Some(Zeroizing::new(p)); } + } + ItemCore::SecureNote(n) => { + if let Some(b) = body { n.body = Zeroizing::new(b); } + } + ItemCore::Identity(i) => { + if let Some(v) = full_name { i.full_name = Some(v); } + if let Some(v) = email { i.email = Some(v); } + if let Some(v) = phone { i.phone = Some(v); } + } + _ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"), + } + + item.modified = now_unix(); + let item_rel = vault.save_item(&collection, &item)?; + + let mut manifest = vault.load_manifest()?; + upsert_org_entry(&mut manifest, &item, &collection); + vault.save_manifest(&manifest)?; + + let subject = format!( + "org edit: {} ({})", + crate::helpers::sanitize_for_commit(&item.title), + item.id.as_str() + ); + let commit_msg = format!( + "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", + caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() + ); + crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; + crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; + + println!("Updated {}", item.id.as_str()); + Ok(()) +} + /// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault /// `Manifest::upsert`. Keyed by item id. fn upsert_org_entry( diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 019bc84..9951ab9 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -522,8 +522,21 @@ pub(crate) enum OrgCommands { List { #[arg(long)] trashed: bool, }, - // Item subcommands (Edit/Rm/Restore/Purge) are added by - // Tasks B12–B13, which extend this enum. + /// Edit an org item's fields (flag-driven; blank flags keep current values). + Edit { + /// Item id or case-insensitive title substring. + query: String, + #[arg(long)] title: Option, + #[arg(long)] username: Option, + #[arg(long)] url: Option, + #[arg(long)] password: Option, + #[arg(long)] body: Option, + #[arg(long)] email: Option, + #[arg(long)] phone: Option, + #[arg(long)] full_name: Option, + }, + // Item subcommands (Rm/Restore/Purge) are added by + // Task B13, which extends this enum. } #[derive(clap::Subcommand)] @@ -672,8 +685,12 @@ fn main() -> Result<()> { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_list(&d, trashed)?; } - // Item dispatch arms (Edit/Rm/Restore/Purge) added by - // Tasks B12–B13. + OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { + let d = crate::org_session::org_dir(dir_path)?; + commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; + } + // Item dispatch arms (Rm/Restore/Purge) added by + // Task B13. } Ok(()) } diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index e8528c0..4b53ea7 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -147,3 +147,33 @@ fn org_add_rejects_unknown_collection() { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}"); } + +#[test] +fn org_edit_updates_fields_and_commits_update_trailer() { + let f = OrgFixture::new(); + let owner = f.owner_member_id(); + assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success()); + assert!(f.run(&["org", "grant", &owner, "prod"]).status.success()); + assert!(f.run(&[ + "org", "add", "login", "--collection", "prod", + "--title", "Mail", "--username", "old", "--password", "pw", + ]).status.success()); + + // Edit the username. + let out = f.run(&[ + "org", "edit", "Mail", "--username", "new-user", + ]); + assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); + + // get --show reflects the new username. + let out = f.run(&["org", "get", "Mail", "--show"]); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); + + let log = Command::new("git") + .args(["-C", f.vault_str(), "log", "-1", "--format=%B"]) + .output().unwrap(); + let body = String::from_utf8_lossy(&log.stdout).to_string(); + assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}"); + assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}"); +}