From 290bc4e2d04488201b1c70c5f1b61de181de84a6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 20 Jun 2026 20:43:03 -0400 Subject: [PATCH] feat(cli/org): interactive per-type org edit via shared edit helpers --- crates/relicario-cli/src/commands/org.rs | 57 +++++++++--------------- crates/relicario-cli/src/main.rs | 16 +++---- crates/relicario-cli/tests/org_items.rs | 53 ++++++++++++++++++---- 3 files changed, 72 insertions(+), 54 deletions(-) diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs index 33dd3ae..675b7b5 100644 --- a/crates/relicario-cli/src/commands/org.rs +++ b/crates/relicario-cli/src/commands/org.rs @@ -991,21 +991,9 @@ 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<()> { +pub fn run_edit(dir: &Path, query: &str, totp_qr: 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()?; @@ -1017,31 +1005,28 @@ pub fn run_edit( crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; let mut item = vault.load_item(&collection, &id)?; + eprintln!( + "Editing: {} ({}) — leave a prompt blank to keep the current value.", + item.title, + item.id.as_str() + ); + if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? { + item.title = v; + } - if let Some(t) = title { item.title = t; } - + let history = &mut item.field_history; 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"), + ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?, + ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?, + ItemCore::Identity(i) => ib::edit_identity(i)?, + ItemCore::Card(c) => ib::edit_card(c, history)?, + ItemCore::Key(k) => ib::edit_key(k, history)?, + ItemCore::Document(_) => ib::edit_document_message(), + ItemCore::Totp(t) => ib::edit_totp(t, history)?, } 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)?; @@ -1053,12 +1038,14 @@ pub fn run_edit( ); 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() + 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()); + println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection); Ok(()) } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 29e0b87..a52c153 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -535,18 +535,12 @@ pub(crate) enum OrgCommands { List { #[arg(long)] trashed: bool, }, - /// Edit an org item's fields (flag-driven; blank flags keep current values). + /// Edit an org item interactively (per-type prompts; blank keeps current). 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, + /// Replace the login TOTP secret from a QR image. + #[arg(long)] totp_qr: Option, }, /// Soft-delete an org item (reversible via `org restore`). Rm { query: String }, @@ -754,9 +748,9 @@ fn main() -> Result<()> { let d = crate::org_session::org_dir(dir_path)?; commands::org::run_list(&d, trashed)?; } - OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { + OrgCommands::Edit { query, totp_qr } => { let d = crate::org_session::org_dir(dir_path)?; - commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; + commands::org::run_edit(&d, &query, totp_qr)?; } OrgCommands::Rm { query } => { let d = crate::org_session::org_dir(dir_path)?; diff --git a/crates/relicario-cli/tests/org_items.rs b/crates/relicario-cli/tests/org_items.rs index bd7197c..f5f6a64 100644 --- a/crates/relicario-cli/tests/org_items.rs +++ b/crates/relicario-cli/tests/org_items.rs @@ -184,21 +184,17 @@ fn org_add_rejects_unknown_collection() { #[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()); + f.create_collection_and_grant("prod"); 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", - ]); + // org edit is now interactive per-type: keep title, set username=new-user, + // keep URL, decline password change. + let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n"); 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}"); @@ -321,3 +317,44 @@ fn org_add_totp_with_secret_flag_round_trips() { assert!(stdout.contains("AWS root"), "title missing: {stdout}"); assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}"); } + +#[test] +fn org_edit_card_interactive_changes_holder() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + let out = f.run_stdin( + &[ + "org", "add", "card", "--collection", "eng", "--title", "Corp Visa", + "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin", + ], + "4111111111111111\n123\n4321\n", + ); + assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr)); + + // Interactive edit: keep title, set holder, decline number change. + let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n"); + assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "Corp Visa"]); + let stdout = String::from_utf8_lossy(&got.stdout).to_string(); + assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}"); + assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}"); + assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}"); +} + +#[test] +fn org_edit_totp_interactive_changes_issuer() { + let f = OrgFixture::new(); + f.create_collection_and_grant("eng"); + assert!(f.run(&[ + "org", "add", "totp", "--collection", "eng", "--title", "AWS root", + "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP", + ]).status.success()); + + // Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change. + let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n"); + assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr)); + + let got = f.run(&["org", "get", "AWS root"]); + assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take"); +}