feat(cli/org): interactive per-type org edit via shared edit helpers

This commit is contained in:
adlee-was-taken
2026-06-20 20:43:03 -04:00
parent 82feb49ab4
commit 290bc4e2d0
3 changed files with 72 additions and 54 deletions

View File

@@ -991,21 +991,9 @@ fn resolve_org_query<'a>(
}
}
pub fn run_edit(
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> 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(())
}

View File

@@ -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<String>,
#[arg(long)] username: Option<String>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long)] body: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long)] full_name: Option<String>,
/// Replace the login TOTP secret from a QR image.
#[arg(long)] totp_qr: Option<std::path::PathBuf>,
},
/// 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)?;

View File

@@ -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");
}