feat(cli/org): org add — collection-scoped typed item create with grant guard

This commit is contained in:
adlee-was-taken
2026-06-20 14:00:21 -04:00
parent 6a16523ee0
commit 87b1d166c2
3 changed files with 336 additions and 5 deletions

View File

@@ -6,7 +6,8 @@ use std::path::Path;
use anyhow::{Context, Result};
use relicario_core::{
generate_org_key, wrap_org_key,
CollectionDef, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, OrgRole, OrgMember,
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
OrgRole, OrgMember,
encrypt_org_manifest,
};
@@ -744,6 +745,132 @@ pub fn run_audit(
Ok(())
}
/// Item kinds `org add` supports without interactive prompts.
pub enum OrgAddKind {
Login {
title: String,
username: Option<String>,
url: Option<String>,
password: Option<String>,
},
SecureNote {
title: String,
body: String,
},
Identity {
title: String,
full_name: Option<String>,
email: Option<String>,
phone: Option<String>,
},
}
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore};
use zeroize::Zeroizing;
let mut item = match kind {
OrgAddKind::Login { title, username, url, password } => {
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = password.map(Zeroizing::new);
Item::new(title, ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
}))
}
OrgAddKind::SecureNote { title, body } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}))
}
OrgAddKind::Identity { title, full_name, email, phone } => {
Item::new(title, ItemCore::Identity(IdentityCore {
full_name,
address: None,
phone,
email,
date_of_birth: None,
}))
}
};
item.tags = tags;
Ok(item)
}
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
use crate::org_session::UnlockedOrgVault;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
// Slug must exist in collections.json…
let collections = vault.load_collections()?;
if !collections.contains_slug(collection) {
anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
}
// …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?;
let item = build_org_item(kind, tags)?;
let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the
// encrypted manifest).
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, collection);
vault.save_manifest(&manifest)?;
let subject = format!(
"org add: {} ({})",
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()
);
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\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 add: git add",
)?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
Ok(())
}
/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
/// `Manifest::upsert`. Keyed by item id.
fn upsert_org_entry(
manifest: &mut relicario_core::OrgManifest,
item: &Item,
collection: &str,
) {
let entry = relicario_core::OrgManifestEntry {
id: item.id.clone(),
r#type: item.r#type,
title: item.title.clone(),
tags: item.tags.clone(),
modified: item.modified,
trashed_at: item.trashed_at,
collection: collection.to_string(),
};
if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
*slot = entry;
} else {
manifest.entries.push(entry);
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -507,8 +507,42 @@ pub(crate) enum OrgCommands {
#[arg(long, default_value = "table")]
format: String,
},
// Item subcommands (Add/Get/List/Edit/Rm/Restore/Purge) are added by
// Tasks B10B13, which extend this enum.
/// Add an item to a collection in the org vault.
Add {
#[command(subcommand)]
kind: OrgAddKind,
},
// Item subcommands (Get/List/Edit/Rm/Restore/Purge) are added by
// Tasks B11B13, which extend this enum.
}
#[derive(clap::Subcommand)]
pub(crate) enum OrgAddKind {
/// A login (username / url / password).
Login {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] username: Option<String>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
/// A secure note.
SecureNote {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] body: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
/// An identity record.
Identity {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] full_name: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
}
fn main() -> Result<()> {
@@ -599,8 +633,29 @@ fn main() -> Result<()> {
commands::org::run_audit(&d, since.as_deref(), member.as_deref(),
collection.as_deref(), action.as_deref(), &format)?;
}
// Item dispatch arms (Add/Get/List/Edit/Rm/Restore/Purge) added by
// Tasks B10B13.
OrgCommands::Add { kind } => {
let d = crate::org_session::org_dir(dir_path)?;
let (collection, add_kind, tags) = match kind {
OrgAddKind::Login { collection, title, username, url, password, tags } => (
collection,
commands::org::OrgAddKind::Login { title, username, url, password },
tags,
),
OrgAddKind::SecureNote { collection, title, body, tags } => (
collection,
commands::org::OrgAddKind::SecureNote { title, body },
tags,
),
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
collection,
commands::org::OrgAddKind::Identity { title, full_name, email, phone },
tags,
),
};
commands::org::run_add(&d, &collection, add_kind, tags)?;
}
// Item dispatch arms (Get/List/Edit/Rm/Restore/Purge) added by
// Tasks B11B13.
}
Ok(())
}