feat(cli): relicario add login with flag + interactive prompting

Unlocks vault, builds LoginCore from flags (password via rpassword if
--password-prompt), saves item + manifest, commits via hardened git.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 22:13:17 -04:00
parent 5dce2c10f9
commit 89b22cb089
2 changed files with 82 additions and 1 deletions

View File

@@ -22,6 +22,7 @@ rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zeroize = "1"
url = "2"
[dev-dependencies]
assert_cmd = "2"

View File

@@ -350,7 +350,87 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
Ok(())
}
fn cmd_add(_kind: AddKind) -> Result<()> { bail!("not yet implemented"); }
fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => {
use relicario_core::item_types::LoginCore;
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s)
.with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(rpassword::prompt_password("Password: ")?))
} else {
None
};
let core = ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
});
let mut item = Item::new(title, core);
item.group = group;
item.tags = tags;
item.favorite = favorite;
item
}
// Task 8 fills in the other variants.
_ => anyhow::bail!("item kind not yet implemented"),
};
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(())
}
fn prompt(label: &str) -> Result<String> {
eprint!("{label}: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
Ok(trimmed)
}
fn prompt_optional(label: &str) -> Result<Option<String>> {
eprint!("{label} (leave blank to skip): ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> {
let mut args: Vec<&str> = vec!["add"];
args.extend_from_slice(paths);
let status = crate::helpers::git_command(vault.root(), &args).status()?;
if !status.success() { anyhow::bail!("git add failed"); }
let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?;
if !status.success() { anyhow::bail!("git commit failed"); }
Ok(())
}
fn cmd_get(_query: String, _show: bool, _copy: bool) -> Result<()> { bail!("not yet implemented"); }
fn cmd_list(_t: Option<String>, _g: Option<String>, _tag: Option<String>, _trashed: bool) -> Result<()> { bail!("not yet implemented"); }
fn cmd_edit(_query: String) -> Result<()> { bail!("not yet implemented"); }