Compare commits
26 Commits
feature/ar
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4777cc0bb | ||
|
|
4b657e71f1 | ||
|
|
7901c2758d | ||
|
|
2e41e0bae0 | ||
|
|
b9bd152e9d | ||
|
|
89090a8f30 | ||
|
|
73a2579fa8 | ||
|
|
f3d6c0a880 | ||
|
|
97c8f994e1 | ||
|
|
f3cdbed7b6 | ||
|
|
2d1f0926ae | ||
|
|
64275bc64f | ||
|
|
2d5b86bf20 | ||
|
|
08bdfbc7c4 | ||
|
|
3811b07014 | ||
|
|
6676d2502b | ||
|
|
615afd7483 | ||
|
|
c2f3c35ac9 | ||
|
|
530c479f19 | ||
|
|
da7d7d162c | ||
|
|
13c2fc2bd7 | ||
|
|
b9b07ec68d | ||
|
|
17bde162cd | ||
|
|
52400230e0 | ||
|
|
272b6a3845 | ||
|
|
02e05f7a05 |
313
crates/relicario-cli/src/commands/add.rs
Normal file
313
crates/relicario-cli/src/commands/add.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! `relicario add <kind>` — create a new item of the given type.
|
||||
//!
|
||||
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
|
||||
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
|
||||
//! `Document` builder is the only one that needs the unlocked vault (for the
|
||||
//! attachment-cap settings + writing the encrypted blob alongside the item).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::AddKind;
|
||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||
use crate::prompt::{prompt, prompt_optional, prompt_secret};
|
||||
|
||||
pub 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, totp_qr } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
||||
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
||||
build_secure_note_item(title, body_prompt, group, tags)?,
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
||||
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
||||
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
||||
build_card_item(title, holder, expiry, kind, group, tags)?,
|
||||
AddKind::Key { title, label, algorithm, group, tags } =>
|
||||
build_key_item(title, label, algorithm, group, tags)?,
|
||||
AddKind::Document { title, file, group, tags } =>
|
||||
build_document_item(&vault, title, file, group, tags)?,
|
||||
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
||||
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
||||
};
|
||||
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let mut paths: Vec<String> = vec![
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
"manifest.enc".into(),
|
||||
];
|
||||
for att in &item.attachments {
|
||||
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
||||
}
|
||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
|
||||
|
||||
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_login_item(
|
||||
title: Option<String>,
|
||||
username: Option<String>,
|
||||
url: Option<String>,
|
||||
password_prompt: bool,
|
||||
password: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
favorite: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||||
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(prompt_secret("Password: ")?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let totp = if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||
username, password, url: parsed_url, totp,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
item.favorite = favorite;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_secure_note_item(
|
||||
title: Option<String>,
|
||||
body_prompt: bool,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::SecureNoteCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let body = if body_prompt {
|
||||
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
s
|
||||
} else {
|
||||
prompt("Body")?
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
|
||||
body: Zeroizing::new(body),
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_identity_item(
|
||||
title: Option<String>,
|
||||
full_name: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
date_of_birth: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::IdentityCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let dob = match date_of_birth {
|
||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||
None => None,
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name, address: None, phone, email, date_of_birth: dob,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_card_item(
|
||||
title: Option<String>,
|
||||
holder: Option<String>,
|
||||
expiry: Option<String>,
|
||||
kind: String,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{CardCore, CardKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
||||
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
|
||||
let pin = if pin.is_empty() { None } else { Some(pin) };
|
||||
|
||||
let parsed_expiry = match expiry {
|
||||
Some(s) => Some(parse_month_year(&s)?),
|
||||
None => None,
|
||||
};
|
||||
let parsed_kind = match kind.as_str() {
|
||||
"credit" => CardKind::Credit,
|
||||
"debit" => CardKind::Debit,
|
||||
"gift" => CardKind::Gift,
|
||||
"loyalty" => CardKind::Loyalty,
|
||||
"other" => CardKind::Other,
|
||||
other => anyhow::bail!("unknown card kind: {other}"),
|
||||
};
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
||||
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_key_item(
|
||||
title: Option<String>,
|
||||
label: Option<String>,
|
||||
algorithm: Option<String>,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::KeyCore;
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
||||
let mut key_material = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
||||
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||
let public_key = prompt_optional("Public key (blank to skip)")?;
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
||||
key_material: Zeroizing::new(key_material),
|
||||
label, public_key, algorithm,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
fn build_document_item(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
title: Option<String>,
|
||||
file: PathBuf,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::DocumentCore;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||
use std::fs;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let caps = vault.load_settings()?.attachment_caps;
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
|
||||
let primary_attachment = enc.id.clone();
|
||||
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
|
||||
filename: filename.clone(),
|
||||
mime_type: mime_type.clone(),
|
||||
primary_attachment: primary_attachment.clone(),
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
item.attachments.push(AttachmentRef {
|
||||
id: primary_attachment.clone(),
|
||||
filename, mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: item.created,
|
||||
});
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_totp_item(
|
||||
title: Option<String>,
|
||||
issuer: Option<String>,
|
||||
label: Option<String>,
|
||||
secret: Option<String>,
|
||||
period: u32,
|
||||
digits: u8,
|
||||
algorithm: String,
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||
let secret_b32 = match secret {
|
||||
Some(s) => s,
|
||||
None => prompt_secret("TOTP secret (base32): ")?,
|
||||
};
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
let algo = match algorithm.to_ascii_lowercase().as_str() {
|
||||
"sha1" => TotpAlgorithm::Sha1,
|
||||
"sha256" => TotpAlgorithm::Sha256,
|
||||
"sha512" => TotpAlgorithm::Sha512,
|
||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||
};
|
||||
|
||||
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: algo,
|
||||
digits,
|
||||
period_seconds: period,
|
||||
kind: TotpKind::Totp,
|
||||
},
|
||||
issuer, label,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
Ok(item)
|
||||
}
|
||||
175
crates/relicario-cli/src/commands/attach.rs
Normal file
175
crates/relicario-cli/src/commands/attach.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::parse::guess_mime;
|
||||
|
||||
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
let settings = vault.load_settings()?;
|
||||
let caps = settings.attachment_caps;
|
||||
|
||||
if item.attachments.len() as u32 >= caps.per_item_max_count {
|
||||
anyhow::bail!("item already has {} attachments (max {})",
|
||||
item.attachments.len(), caps.per_item_max_count);
|
||||
}
|
||||
|
||||
let bytes = fs::read(&file)
|
||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||
|
||||
// Check per-vault total attachment bytes cap (audit I3).
|
||||
let current_total: u64 = manifest.items.values()
|
||||
.flat_map(|e| &e.attachment_summaries)
|
||||
.map(|s| s.size)
|
||||
.sum();
|
||||
let new_size = bytes.len() as u64;
|
||||
let hard_cap = caps.per_vault_hard_cap_bytes;
|
||||
let soft_cap = caps.per_vault_soft_cap_bytes;
|
||||
if current_total + new_size > hard_cap {
|
||||
anyhow::bail!(
|
||||
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
|
||||
current_total, new_size, hard_cap
|
||||
);
|
||||
}
|
||||
if current_total + new_size > soft_cap {
|
||||
eprintln!(
|
||||
"warning: vault attachments will exceed soft cap ({} bytes)",
|
||||
soft_cap
|
||||
);
|
||||
}
|
||||
|
||||
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
|
||||
let filename = file.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let mime_type = guess_mime(&filename);
|
||||
let aref = AttachmentRef {
|
||||
id: enc.id.clone(),
|
||||
filename,
|
||||
mime_type,
|
||||
size: bytes.len() as u64,
|
||||
created: now_unix(),
|
||||
};
|
||||
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
fs::create_dir_all(&att_dir)?;
|
||||
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||
|
||||
item.attachments.push(aref);
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let paths = [
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
"manifest.enc".into(),
|
||||
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
|
||||
];
|
||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
super::commit_paths(&vault, &format!("attach: {} → {} ({})",
|
||||
crate::helpers::sanitize_for_commit(&file.display().to_string()),
|
||||
crate::helpers::sanitize_for_commit(&item.title),
|
||||
item.id.as_str()), &path_refs)?;
|
||||
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_attachments(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||
for a in &item.attachments {
|
||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::decrypt_attachment;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
let path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", aid));
|
||||
let bytes = fs::read(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let plaintext = decrypt_attachment(&bytes, vault.key())?;
|
||||
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
|
||||
fs::write(&out_path, plaintext.as_slice())
|
||||
.with_context(|| format!("failed to write {}", out_path.display()))?;
|
||||
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::ItemCore;
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
|
||||
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||
|
||||
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||
if let ItemCore::Document(d) = &item.core {
|
||||
if d.primary_attachment.as_str() == aid {
|
||||
anyhow::bail!(
|
||||
"cannot detach the primary attachment of a Document item; \
|
||||
use `purge {}` to delete the whole item",
|
||||
item.title,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let removed = item.attachments.remove(pos);
|
||||
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||
.join(format!("{}.enc", removed.id.as_str()));
|
||||
if blob_path.exists() {
|
||||
fs::remove_file(&blob_path)
|
||||
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||
super::commit_paths(
|
||||
&vault,
|
||||
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&item_path, "manifest.enc", &blob_relpath],
|
||||
)?;
|
||||
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||
Ok(())
|
||||
}
|
||||
303
crates/relicario-cli/src/commands/backup.rs
Normal file
303
crates/relicario-cli/src/commands/backup.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! `relicario backup export` / `relicario backup restore` — pack/unpack the
|
||||
//! encrypted `.relbak` envelope.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::BackupAction;
|
||||
|
||||
pub fn cmd_backup(action: BackupAction) -> Result<()> {
|
||||
match action {
|
||||
BackupAction::Export { out, include_image, image, no_history } => {
|
||||
cmd_backup_export(out, include_image, image, no_history)
|
||||
}
|
||||
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cmd_backup_export(
|
||||
out: PathBuf,
|
||||
include_image: bool,
|
||||
image: Option<PathBuf>,
|
||||
no_history: bool,
|
||||
) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::{backup, validate_passphrase_strength};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
|
||||
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
|
||||
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||
};
|
||||
let confirm = if crate::test_backup_passphrase_override().is_some() {
|
||||
passphrase.clone()
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||
};
|
||||
if passphrase.as_str() != confirm.as_str() {
|
||||
anyhow::bail!("passphrases do not match");
|
||||
}
|
||||
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||
anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
|
||||
}
|
||||
|
||||
// Read everything from disk that the envelope needs.
|
||||
let salt = fs::read(root.join(".relicario").join("salt"))
|
||||
.with_context(|| "failed to read .relicario/salt")?;
|
||||
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.with_context(|| "failed to read .relicario/params.json")?;
|
||||
// devices.json was removed in the B1 security audit fix; fall back to
|
||||
// an empty array so backups of post-B1 vaults still pack cleanly.
|
||||
// Task 12 will remove the devices field from the backup format entirely.
|
||||
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
let manifest_enc = fs::read(root.join("manifest.enc"))
|
||||
.with_context(|| "failed to read manifest.enc")?;
|
||||
let settings_enc = fs::read(root.join("settings.enc"))
|
||||
.with_context(|| "failed to read settings.enc")?;
|
||||
|
||||
// Items.
|
||||
let mut item_files = Vec::new();
|
||||
let items_dir = root.join("items");
|
||||
if items_dir.is_dir() {
|
||||
for entry in fs::read_dir(&items_dir)? {
|
||||
let p = entry?.path();
|
||||
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||
let id = p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
|
||||
.to_string();
|
||||
let bytes = fs::read(&p)?;
|
||||
item_files.push((id, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments. Layout: attachments/<item_id>/<aid>.enc
|
||||
let mut attach_files = Vec::new();
|
||||
let attach_dir = root.join("attachments");
|
||||
if attach_dir.is_dir() {
|
||||
for entry in fs::read_dir(&attach_dir)? {
|
||||
let item_dir = entry?.path();
|
||||
if !item_dir.is_dir() { continue; }
|
||||
let item_id = item_dir.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
|
||||
.to_string();
|
||||
for sub in fs::read_dir(&item_dir)? {
|
||||
let p = sub?.path();
|
||||
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
|
||||
let aid = p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
|
||||
.to_string();
|
||||
let bytes = fs::read(&p)?;
|
||||
attach_files.push((item_id.clone(), aid, bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional reference image.
|
||||
let image_bytes = if include_image {
|
||||
let path = match image {
|
||||
Some(p) => p,
|
||||
None => crate::session::get_image_path()?,
|
||||
};
|
||||
Some(fs::read(&path)
|
||||
.with_context(|| format!("failed to read reference image {}", path.display()))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Optional .git/ tar.
|
||||
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
|
||||
|
||||
let items_refs: Vec<backup::BackupItem> = item_files.iter()
|
||||
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
|
||||
.collect();
|
||||
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
|
||||
.map(|(iid, aid, bytes)| backup::BackupAttachment {
|
||||
item_id: iid.clone(),
|
||||
attachment_id: aid.clone(),
|
||||
ciphertext: bytes,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let input = backup::BackupInput {
|
||||
salt: &salt,
|
||||
params_json: ¶ms_json,
|
||||
devices_json: &devices_json,
|
||||
manifest_enc: &manifest_enc,
|
||||
settings_enc: &settings_enc,
|
||||
items: items_refs,
|
||||
attachments: attach_refs,
|
||||
reference_jpg: image_bytes.as_deref(),
|
||||
git_archive: git_archive.as_deref(),
|
||||
};
|
||||
|
||||
let bytes = backup::pack_backup(input, &passphrase)?;
|
||||
|
||||
// atomic_write via the existing pattern: write `.tmp`, rename.
|
||||
let tmp = {
|
||||
let mut t = out.as_os_str().to_owned();
|
||||
t.push(".tmp");
|
||||
PathBuf::from(t)
|
||||
};
|
||||
fs::write(&tmp, &bytes)
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, &out)
|
||||
.with_context(|| format!("failed to rename {}", out.display()))?;
|
||||
|
||||
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
|
||||
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
|
||||
|
||||
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
|
||||
eprintln!(
|
||||
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
|
||||
out.display(), mib
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
|
||||
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut buf);
|
||||
builder.append_dir_all(".", dir)
|
||||
.with_context(|| format!("failed to tar {}", dir.display()))?;
|
||||
builder.finish().with_context(|| "failed to finalize git tar")?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::backup;
|
||||
use relicario_core::{ItemId, AttachmentId};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let target = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
std::env::current_dir()?.join(&target)
|
||||
};
|
||||
|
||||
if target.join(".relicario").exists() {
|
||||
anyhow::bail!(
|
||||
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
fs::create_dir_all(&target)
|
||||
.with_context(|| format!("failed to create target {}", target.display()))?;
|
||||
|
||||
// Read input file.
|
||||
let bytes = fs::read(&input)
|
||||
.with_context(|| format!("failed to read backup file {}", input.display()))?;
|
||||
|
||||
// Backup passphrase prompt.
|
||||
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
|
||||
};
|
||||
|
||||
let unpacked = backup::unpack_backup(&bytes, &passphrase)
|
||||
.map_err(|e| match e {
|
||||
relicario_core::RelicarioError::Decrypt =>
|
||||
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
|
||||
other => anyhow::anyhow!(other),
|
||||
})?;
|
||||
|
||||
// Write vault layout.
|
||||
let relicario_dir = target.join(".relicario");
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::create_dir_all(target.join("items"))?;
|
||||
fs::create_dir_all(target.join("attachments"))?;
|
||||
|
||||
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
|
||||
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
|
||||
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
|
||||
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
|
||||
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
|
||||
|
||||
for item in &unpacked.items {
|
||||
let item_id = ItemId(item.id.clone());
|
||||
if !item_id.is_valid() {
|
||||
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
|
||||
}
|
||||
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
|
||||
}
|
||||
for a in &unpacked.attachments {
|
||||
let item_id = ItemId(a.item_id.clone());
|
||||
let att_id = AttachmentId(a.attachment_id.clone());
|
||||
if !item_id.is_valid() || !att_id.is_valid() {
|
||||
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
|
||||
}
|
||||
let dir = target.join("attachments").join(&a.item_id);
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
|
||||
}
|
||||
|
||||
// Reference image (if present).
|
||||
if let Some(jpg) = &unpacked.reference_jpg {
|
||||
let path = target.join("reference.jpg");
|
||||
fs::write(&path, jpg)
|
||||
.with_context(|| format!("failed to write reference image {}", path.display()))?;
|
||||
}
|
||||
|
||||
// .git/ history.
|
||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||
let cap = std::cmp::min(
|
||||
(tar_bytes.len() as u64).saturating_mul(100),
|
||||
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||
);
|
||||
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||
let git_dir = target.join(".git");
|
||||
for (rel_path, body) in entries {
|
||||
let dest = git_dir.join(&rel_path);
|
||||
// Paranoid OS-level check even after textual validation in core.
|
||||
if !dest.starts_with(&git_dir) {
|
||||
anyhow::bail!(
|
||||
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||
rel_path.display()
|
||||
);
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("create parent {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
fs::write(&dest, &body).with_context(|| {
|
||||
format!("write {}", dest.display())
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
// No history bundled — start a fresh git repo.
|
||||
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
|
||||
|
||||
// .gitignore — exclude reference image if present.
|
||||
if target.join("reference.jpg").exists() {
|
||||
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
|
||||
}
|
||||
|
||||
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
|
||||
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
|
||||
let msg = format!("restore from backup {now_iso}");
|
||||
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Restored vault to {}. Unlock with your passphrase + reference image.",
|
||||
target.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
255
crates/relicario-cli/src/commands/device.rs
Normal file
255
crates/relicario-cli/src/commands/device.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
//! `relicario device {add, revoke, list}` — device key management.
|
||||
//!
|
||||
//! Note: command bodies live here as `crate::commands::device`. Local key
|
||||
//! storage and git-signing config live separately in `crate::device`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::DeviceAction;
|
||||
|
||||
/// Build a `GiteaClient` from flags or environment variables.
|
||||
fn load_gitea_client(
|
||||
gitea_url: Option<String>,
|
||||
gitea_token: Option<String>,
|
||||
owner: Option<String>,
|
||||
repo: Option<String>,
|
||||
) -> Result<crate::gitea::GiteaClient> {
|
||||
let url = gitea_url
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
|
||||
))?;
|
||||
let token = gitea_token
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
|
||||
))?;
|
||||
let owner = owner
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
|
||||
))?;
|
||||
let repo = repo
|
||||
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
|
||||
))?;
|
||||
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
|
||||
}
|
||||
|
||||
pub fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
|
||||
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
let relicario_dir = root.join(".relicario");
|
||||
let devices_path = relicario_dir.join("devices.json");
|
||||
|
||||
match action {
|
||||
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
|
||||
// Guard: don't overwrite an already-registered device name.
|
||||
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
if existing.iter().any(|d| d.name == name) {
|
||||
anyhow::bail!("a device named '{}' is already registered", name);
|
||||
}
|
||||
|
||||
eprintln!("Generating signing keypair...");
|
||||
let (signing_priv, signing_pub) = generate_keypair()
|
||||
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
|
||||
|
||||
eprintln!("Generating deploy keypair...");
|
||||
let (deploy_priv, deploy_pub) = generate_keypair()
|
||||
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
|
||||
|
||||
// Optionally register deploy key with Gitea.
|
||||
let gitea_key_id: u64 = if no_gitea {
|
||||
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
|
||||
0
|
||||
} else {
|
||||
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
|
||||
let key_title = format!("relicario-{}", name);
|
||||
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
|
||||
client.create_deploy_key(&key_title, &deploy_pub)?
|
||||
};
|
||||
|
||||
// Store keys locally with proper permissions.
|
||||
crate::device::store_device_keys(
|
||||
&name,
|
||||
&signing_priv,
|
||||
&signing_pub,
|
||||
&deploy_priv,
|
||||
&deploy_pub,
|
||||
gitea_key_id,
|
||||
)?;
|
||||
|
||||
// Mark as current device.
|
||||
crate::device::set_current_device(&name)?;
|
||||
|
||||
// Configure git signing + SSH deploy key in the vault repo.
|
||||
crate::device::configure_git_signing(&root, &name)?;
|
||||
|
||||
// Update devices.json.
|
||||
let current_name = name.clone();
|
||||
let mut devices = existing;
|
||||
devices.push(DeviceEntry {
|
||||
name: name.clone(),
|
||||
public_key: signing_pub.clone(),
|
||||
added_at: relicario_core::now_unix(),
|
||||
added_by: current_name,
|
||||
});
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||
|
||||
// Commit the update.
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["add", ".relicario/devices.json"],
|
||||
&format!("device register \"{name}\": git add .relicario/devices.json"),
|
||||
)?;
|
||||
let msg = format!("device: register {}", name);
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["commit", "-m", &msg],
|
||||
&format!("device register \"{name}\": git commit"),
|
||||
)?;
|
||||
|
||||
eprintln!("Device '{}' registered.", name);
|
||||
eprintln!("Signing public key:");
|
||||
eprintln!(" {}", signing_pub);
|
||||
if gitea_key_id != 0 {
|
||||
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
DeviceAction::Revoke { name } => {
|
||||
// Guard: refuse to revoke the currently active device (would lock
|
||||
// the user out). They must add another device first.
|
||||
if let Some(current) = crate::device::current_device()? {
|
||||
if current == name {
|
||||
anyhow::bail!(
|
||||
"cannot revoke the current device '{}' — you would lose \
|
||||
push access. Register another device first.",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load devices.json.
|
||||
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let device = devices
|
||||
.iter()
|
||||
.find(|d| d.name == name)
|
||||
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
|
||||
.clone();
|
||||
|
||||
// Remove from devices.json.
|
||||
devices.retain(|d| d.name != name);
|
||||
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
|
||||
|
||||
// Append to revoked.json.
|
||||
let revoked_path = relicario_dir.join("revoked.json");
|
||||
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let revoked_by = crate::device::current_device()?
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
revoked.push(RevokedEntry {
|
||||
name: name.clone(),
|
||||
public_key: device.public_key.clone(),
|
||||
revoked_at: relicario_core::now_unix(),
|
||||
revoked_by,
|
||||
});
|
||||
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
|
||||
|
||||
// Delete deploy key from Gitea (best-effort — don't fail if it
|
||||
// was already deleted or the config is missing).
|
||||
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
|
||||
if key_id != 0 {
|
||||
// Build client from env vars only (no flags in revoke).
|
||||
match load_gitea_client(None, None, None, None) {
|
||||
Ok(client) => {
|
||||
if let Err(e) = client.delete_deploy_key(key_id) {
|
||||
eprintln!(
|
||||
"warning: failed to delete Gitea deploy key {}: {}",
|
||||
key_id, e
|
||||
);
|
||||
} else {
|
||||
eprintln!("Deleted Gitea deploy key {}.", key_id);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!(
|
||||
"warning: Gitea env vars not set — deploy key {} \
|
||||
not deleted from Gitea.",
|
||||
key_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commit devices.json + revoked.json (always both — revoked.json
|
||||
// was just written above so it is guaranteed to exist).
|
||||
let add_args = [
|
||||
"add",
|
||||
".relicario/devices.json",
|
||||
".relicario/revoked.json",
|
||||
];
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&add_args,
|
||||
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
|
||||
)?;
|
||||
let msg = format!("device: revoke {}", name);
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["commit", "-m", &msg],
|
||||
&format!("device revoke \"{name}\": git commit"),
|
||||
)?;
|
||||
|
||||
eprintln!("Device '{}' revoked.", name);
|
||||
eprintln!("Revoked signing key: {}", device.public_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
DeviceAction::List => {
|
||||
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let current = crate::device::current_device()?.unwrap_or_default();
|
||||
|
||||
if devices.is_empty() {
|
||||
println!("No registered devices.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||
println!("{}", "-".repeat(72));
|
||||
for d in &devices {
|
||||
let marker = if d.name == current { " *" } else { "" };
|
||||
let added = crate::helpers::iso8601(d.added_at);
|
||||
// Show only the first 40 chars of the public key line for readability.
|
||||
let key_prefix: String = d.public_key.chars().take(40).collect();
|
||||
println!("{:<20} {:<20} {}{}",
|
||||
d.name, added, key_prefix, marker);
|
||||
}
|
||||
if !current.is_empty() {
|
||||
println!("\n* = current device");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
171
crates/relicario-cli/src/commands/edit.rs
Normal file
171
crates/relicario-cli/src/commands/edit.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! `relicario edit <query>` — interactive per-type field editing with history capture.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::parse::base32_decode_lenient;
|
||||
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||
|
||||
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
|
||||
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||
item.title, item.id.as_str());
|
||||
|
||||
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
||||
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
|
||||
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
}
|
||||
|
||||
let history = &mut item.field_history;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => edit_identity(i)?,
|
||||
ItemCore::Card(c) => edit_card(c, history)?,
|
||||
ItemCore::Key(k) => edit_key(k, history)?,
|
||||
ItemCore::Document(_) => edit_document_message(),
|
||||
ItemCore::Totp(t) => edit_totp(t, history)?,
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Updated {}", item.id.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
||||
// that touch history-tracked fields take the item's field_history map so
|
||||
// they can record the prior value alongside the change.
|
||||
|
||||
type FieldHistory = std::collections::HashMap<
|
||||
relicario_core::FieldId,
|
||||
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||
>;
|
||||
|
||||
fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||
}
|
||||
if prompt_yesno("Change password?")? {
|
||||
let old = l.password.clone();
|
||||
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||
if let Some(old_pw) = old {
|
||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
l.totp = Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
});
|
||||
eprintln!("TOTP secret set from QR image.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if prompt_yesno("Edit body?")? {
|
||||
let old = n.body.clone();
|
||||
eprintln!("Enter new body; end with Ctrl-D:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
n.body = Zeroizing::new(s);
|
||||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||
if prompt_yesno("Change card number?")? {
|
||||
let old = c.number.clone();
|
||||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||
if let Some(o) = old {
|
||||
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if prompt_yesno("Replace key material?")? {
|
||||
eprintln!("Paste new key material; end with Ctrl-D:");
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
let old = k.key_material.clone();
|
||||
k.key_material = Zeroizing::new(s);
|
||||
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_document_message() {
|
||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||
}
|
||||
|
||||
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||
if prompt_yesno("Change TOTP secret?")? {
|
||||
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||
t.config.secret = Zeroizing::new(new_bytes);
|
||||
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_history(
|
||||
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
|
||||
synthetic_key: &str,
|
||||
old_value: zeroize::Zeroizing<String>,
|
||||
) {
|
||||
use relicario_core::item::FieldHistoryEntry;
|
||||
use relicario_core::time::now_unix;
|
||||
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||||
// custom-field UUIDs can't collide).
|
||||
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||||
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||||
value: old_value,
|
||||
replaced_at: now_unix(),
|
||||
});
|
||||
}
|
||||
68
crates/relicario-cli/src/commands/generate.rs
Normal file
68
crates/relicario-cli/src/commands/generate.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! `relicario generate` — emit a fresh password or BIP39 passphrase.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_generate(
|
||||
length: Option<u32>,
|
||||
bip39: bool,
|
||||
words: Option<u32>,
|
||||
symbols: Option<String>,
|
||||
separator: Option<String>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::{
|
||||
generate_passphrase, generate_password, Capitalization, CharClasses,
|
||||
GeneratorRequest, SymbolCharset,
|
||||
};
|
||||
|
||||
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
|
||||
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
|
||||
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
Some(vault.load_settings()?.generator_defaults)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
|
||||
// vault default is in (Random when no vault).
|
||||
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
|
||||
|
||||
let output = if use_bip39 {
|
||||
let (def_words, def_sep, def_cap) = match &vault_defaults {
|
||||
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
|
||||
(*word_count, separator.clone(), *capitalization)
|
||||
}
|
||||
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||
};
|
||||
generate_passphrase(&GeneratorRequest::Bip39 {
|
||||
word_count: words.unwrap_or(def_words),
|
||||
separator: separator.unwrap_or(def_sep),
|
||||
capitalization: def_cap,
|
||||
})?
|
||||
} else {
|
||||
let (def_length, def_classes, def_charset) = match &vault_defaults {
|
||||
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
|
||||
(*length, *classes, symbol_charset.clone())
|
||||
}
|
||||
_ => (
|
||||
20,
|
||||
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
SymbolCharset::SafeOnly,
|
||||
),
|
||||
};
|
||||
let symbol_charset = match symbols.as_deref() {
|
||||
None => def_charset,
|
||||
Some("safe") => SymbolCharset::SafeOnly,
|
||||
Some("extended") => SymbolCharset::Extended,
|
||||
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||
};
|
||||
generate_password(&GeneratorRequest::Random {
|
||||
length: length.unwrap_or(def_length),
|
||||
classes: def_classes,
|
||||
symbol_charset,
|
||||
})?
|
||||
};
|
||||
|
||||
println!("{}", output.as_str());
|
||||
Ok(())
|
||||
}
|
||||
107
crates/relicario-cli/src/commands/get.rs
Normal file
107
crates/relicario-cli/src/commands/get.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! `relicario get` — print a single item, masking secrets unless `--show`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
use relicario_core::ItemCore;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("ID: {}", item.id.as_str());
|
||||
println!("Title: {}", item.title);
|
||||
println!("Type: {:?}", item.r#type);
|
||||
if let Some(g) = &item.group { println!("Group: {g}"); }
|
||||
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
|
||||
println!("Created: {}", crate::helpers::iso8601(item.created));
|
||||
println!("Modified: {}", crate::helpers::iso8601(item.modified));
|
||||
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
|
||||
println!();
|
||||
|
||||
let primary_secret: Option<Zeroizing<String>> = match &item.core {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
l.password.clone()
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||
else { println!("Body: ********"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Identity(i) => {
|
||||
if let Some(v) = &i.full_name { println!("Name: {v}"); }
|
||||
if let Some(v) = &i.email { println!("Email: {v}"); }
|
||||
if let Some(v) = &i.phone { println!("Phone: {v}"); }
|
||||
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
|
||||
None
|
||||
}
|
||||
ItemCore::Card(c) => {
|
||||
if let Some(h) = &c.holder { println!("Holder: {h}"); }
|
||||
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
|
||||
println!("Kind: {:?}", c.kind);
|
||||
c.number.clone()
|
||||
}
|
||||
ItemCore::Key(k) => {
|
||||
if let Some(l) = &k.label { println!("Label: {l}"); }
|
||||
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
|
||||
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
|
||||
Some(k.key_material.clone())
|
||||
}
|
||||
ItemCore::Document(d) => {
|
||||
println!("Filename: {}", d.filename);
|
||||
println!("MIME: {}", d.mime_type);
|
||||
None
|
||||
}
|
||||
ItemCore::Totp(t) => {
|
||||
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
|
||||
if let Some(l) = &t.label { println!("Label: {l}"); }
|
||||
println!("Period: {}s", t.config.period_seconds);
|
||||
println!("Digits: {}", t.config.digits);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(secret) = primary_secret {
|
||||
if show {
|
||||
println!("Secret: {}", secret.as_str());
|
||||
} else {
|
||||
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
|
||||
}
|
||||
if copy {
|
||||
copy_to_clipboard_then_clear(&secret)?;
|
||||
eprintln!("Copied to clipboard (auto-clears in 30s).");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
|
||||
use arboard::Clipboard;
|
||||
let mut cb = Clipboard::new().context("failed to access clipboard")?;
|
||||
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
|
||||
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
|
||||
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
|
||||
// and then rewrites the clipboard with empty string. Even if the user
|
||||
// copies something else in the interim, we still overwrite once.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
if let Ok(mut cb) = Clipboard::new() {
|
||||
let _ = cb.set_text(String::new());
|
||||
drop(cleared_copy); // zeroize the detached copy
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
88
crates/relicario-cli/src/commands/import.rs
Normal file
88
crates/relicario-cli/src/commands/import.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! `relicario import` — currently only LastPass CSV is supported.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::ImportAction;
|
||||
|
||||
pub fn cmd_import(action: ImportAction) -> Result<()> {
|
||||
match action {
|
||||
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use relicario_core::import_lastpass::parse_lastpass_csv;
|
||||
|
||||
let csv_bytes = fs::read(&csv_path)
|
||||
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
|
||||
|
||||
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
|
||||
|
||||
if items.is_empty() {
|
||||
// Print all warnings so the user sees why nothing imported.
|
||||
for w in &warnings {
|
||||
print_warning(w);
|
||||
}
|
||||
bail!(
|
||||
"imported 0 items from {} — see warnings above",
|
||||
csv_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
|
||||
let total = items.len();
|
||||
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
|
||||
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
vault.save_item(item)?;
|
||||
manifest.upsert(item);
|
||||
written_paths.push(format!("items/{}.enc", item.id.as_str()));
|
||||
|
||||
let n = idx + 1;
|
||||
if n % 50 == 0 || n == total {
|
||||
eprintln!("[{n}/{total}] importing...");
|
||||
}
|
||||
}
|
||||
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
written_paths.push("manifest.enc".into());
|
||||
|
||||
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||
let csv_filename = csv_path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("lastpass.csv");
|
||||
super::commit_paths(
|
||||
&vault,
|
||||
&format!("import: {} items from LastPass ({})", total, csv_filename),
|
||||
&path_refs,
|
||||
)?;
|
||||
|
||||
for w in &warnings {
|
||||
print_warning(w);
|
||||
}
|
||||
// Counts only true skips, not partial imports. Coupled by convention to
|
||||
// the parser's warning message strings: skip messages end in "— skipped",
|
||||
// partial-import messages say "imported without TOTP" / "imported without URL".
|
||||
// If a future warning uses the word "skipped" in any other sense, this filter
|
||||
// will need to switch to an enum tag (see ImportWarning::message).
|
||||
eprintln!(
|
||||
"Imported {}, skipped {} (see warnings above)",
|
||||
total,
|
||||
warnings.iter().filter(|w| w.message.contains("skipped")).count()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
|
||||
let prefix = match &w.title {
|
||||
Some(t) => format!("row {} ({}):", w.row, t),
|
||||
None => format!("row {}:", w.row),
|
||||
};
|
||||
eprintln!("warning: {prefix} {}", w.message);
|
||||
}
|
||||
98
crates/relicario-cli/src/commands/init.rs
Normal file
98
crates/relicario-cli/src/commands/init.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! `relicario init` — bootstrap a fresh vault in the current directory.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
use std::fs;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use relicario_core::{
|
||||
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
|
||||
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let root = std::env::current_dir()?;
|
||||
let relicario_dir = root.join(".relicario");
|
||||
if relicario_dir.exists() {
|
||||
anyhow::bail!(".relicario/ already exists in {}", root.display());
|
||||
}
|
||||
|
||||
// Passphrase with strength gate (audit H3).
|
||||
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
|
||||
// TTY prompt so integration tests can run without a real TTY.
|
||||
let passphrase = if let Some(p) = crate::test_passphrase_override() {
|
||||
Zeroizing::new(p)
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
|
||||
};
|
||||
let confirm = if crate::test_passphrase_override().is_some() {
|
||||
passphrase.clone()
|
||||
} else {
|
||||
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
|
||||
};
|
||||
if passphrase.as_str() != confirm.as_str() {
|
||||
anyhow::bail!("passphrases do not match");
|
||||
}
|
||||
if let Err(e) = validate_passphrase_strength(&passphrase) {
|
||||
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
|
||||
}
|
||||
|
||||
// Image secret: 32 random bytes, embedded in the carrier.
|
||||
let image_secret = {
|
||||
let mut buf = Zeroizing::new([0u8; 32]);
|
||||
OsRng.fill_bytes(buf.as_mut_slice());
|
||||
buf
|
||||
};
|
||||
let carrier = fs::read(&image)
|
||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||
fs::write(&output, &stego)
|
||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||
|
||||
// Vault salt + KDF params.
|
||||
let mut salt = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
|
||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||
|
||||
fs::create_dir_all(&relicario_dir)?;
|
||||
fs::create_dir_all(root.join("items"))?;
|
||||
fs::create_dir_all(root.join("attachments"))?;
|
||||
fs::write(relicario_dir.join("salt"), salt)?;
|
||||
fs::write(
|
||||
relicario_dir.join("params.json"),
|
||||
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?,
|
||||
)?;
|
||||
let manifest = Manifest::new();
|
||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||
let settings = VaultSettings::default();
|
||||
fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;
|
||||
|
||||
// .gitignore excludes the reference image.
|
||||
let fname = output.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
|
||||
.to_string_lossy();
|
||||
let gitignore = format!("{fname}\n");
|
||||
fs::write(root.join(".gitignore"), gitignore)?;
|
||||
|
||||
// git init + initial commit via hardened wrapper.
|
||||
crate::helpers::git_run(&root, &["init"], "init: git init")?;
|
||||
let _ = crate::helpers::git_command(&root, &[
|
||||
"add", ".gitignore", ".relicario/params.json",
|
||||
".relicario/salt", "manifest.enc", "settings.enc",
|
||||
]).status()?;
|
||||
crate::helpers::git_run(
|
||||
&root,
|
||||
&["commit", "-m", "init: new Relicario vault (format v2)"],
|
||||
"init: git commit",
|
||||
)?;
|
||||
|
||||
eprintln!("Vault initialized at {}", root.display());
|
||||
eprintln!("Reference image: {}", output.display());
|
||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||
Ok(())
|
||||
}
|
||||
103
crates/relicario-cli/src/commands/list.rs
Normal file
103
crates/relicario-cli/src/commands/list.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! `relicario list` and `relicario history` — both read-only browse paths.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_list(
|
||||
type_filter: Option<String>,
|
||||
group_filter: Option<String>,
|
||||
tag_filter: Option<String>,
|
||||
trashed: bool,
|
||||
) -> Result<()> {
|
||||
use relicario_core::ItemType;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
Some("login") => Some(ItemType::Login),
|
||||
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
|
||||
Some("identity") => Some(ItemType::Identity),
|
||||
Some("card") => Some(ItemType::Card),
|
||||
Some("key") => Some(ItemType::Key),
|
||||
Some("document") => Some(ItemType::Document),
|
||||
Some("totp") => Some(ItemType::Totp),
|
||||
Some(other) => anyhow::bail!("unknown type filter: {other}"),
|
||||
};
|
||||
|
||||
let mut entries: Vec<_> = manifest.items.values()
|
||||
.filter(|e| {
|
||||
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
|
||||
})
|
||||
.filter(|e| match parsed_type {
|
||||
Some(t) => e.r#type == t,
|
||||
None => true,
|
||||
})
|
||||
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
|
||||
if entries.is_empty() {
|
||||
eprintln!("(no items match)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||
for e in entries {
|
||||
let fav = if e.favorite { " *" } else { "" };
|
||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
println!("History for {} ({})", item.title, item.id.as_str());
|
||||
println!();
|
||||
|
||||
// Filter and sort the field-id keys so output is deterministic.
|
||||
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut printed_any = false;
|
||||
for fid in keys {
|
||||
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||
if let Some(filter) = &field {
|
||||
if display_name != filter && fid.0 != *filter { continue; }
|
||||
}
|
||||
let entries = &item.field_history[fid];
|
||||
if entries.is_empty() { continue; }
|
||||
printed_any = true;
|
||||
|
||||
println!("{display_name} ({} {})",
|
||||
entries.len(),
|
||||
if entries.len() == 1 { "entry" } else { "entries" });
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||
if show {
|
||||
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||
} else {
|
||||
println!(" [{i}] {ts} ********");
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if !printed_any {
|
||||
if field.is_some() {
|
||||
println!("no history for the requested field");
|
||||
} else {
|
||||
println!("no history captured for this item");
|
||||
}
|
||||
} else if !show {
|
||||
println!("(use --show to reveal values)");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
60
crates/relicario-cli/src/commands/mod.rs
Normal file
60
crates/relicario-cli/src/commands/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Per-command modules — one file per top-level subcommand.
|
||||
//!
|
||||
//! `main.rs` holds the clap surface (argument enums) and the dispatch
|
||||
//! `match`; the actual command bodies live here. Helpers shared between
|
||||
//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in
|
||||
//! this file as `pub(crate)` so siblings can pull them in via
|
||||
//! `use crate::commands::*`.
|
||||
|
||||
pub mod add;
|
||||
pub mod attach;
|
||||
pub mod backup;
|
||||
pub mod device;
|
||||
pub mod edit;
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
pub mod rate;
|
||||
pub mod recovery_qr;
|
||||
pub mod settings;
|
||||
pub mod status;
|
||||
pub mod sync;
|
||||
pub mod trash;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub(crate) fn commit_paths(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
message: &str,
|
||||
paths: &[&str],
|
||||
) -> Result<()> {
|
||||
let mut args: Vec<&str> = vec!["add"];
|
||||
args.extend_from_slice(paths);
|
||||
crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", message],
|
||||
&format!("commit \"{message}\": git commit"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_query<'a>(
|
||||
manifest: &'a relicario_core::Manifest,
|
||||
query: &str,
|
||||
) -> Result<&'a relicario_core::ManifestEntry> {
|
||||
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
|
||||
return Ok(entry);
|
||||
}
|
||||
let hits: Vec<_> = manifest.search(query);
|
||||
match hits.len() {
|
||||
0 => anyhow::bail!("no item matches `{query}`"),
|
||||
1 => Ok(hits[0]),
|
||||
_ => {
|
||||
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
|
||||
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
28
crates/relicario-cli/src/commands/rate.rs
Normal file
28
crates/relicario-cli/src/commands/rate.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! `relicario rate` — score a passphrase via zxcvbn.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_rate(passphrase: String) -> Result<()> {
|
||||
let pw: String = if passphrase == "-" {
|
||||
use std::io::BufRead;
|
||||
let stdin = std::io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line)?;
|
||||
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
||||
} else {
|
||||
passphrase
|
||||
};
|
||||
let est = relicario_core::generators::rate_passphrase(&pw);
|
||||
let label = match est.score {
|
||||
0 => "very weak",
|
||||
1 => "weak",
|
||||
2 => "fair",
|
||||
3 => "good",
|
||||
4 => "strong",
|
||||
_ => "?",
|
||||
};
|
||||
println!("score: {}/4 ({})", est.score, label);
|
||||
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
||||
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||
Ok(())
|
||||
}
|
||||
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
69
crates/relicario-cli/src/commands/recovery_qr.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::RecoveryQrCmd;
|
||||
|
||||
pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
|
||||
match cmd {
|
||||
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
|
||||
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_generate() -> Result<()> {
|
||||
use relicario_core::{generate_recovery_qr, imgsecret};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let image_path = crate::session::get_image_path()?;
|
||||
let image_bytes = std::fs::read(&image_path)
|
||||
.with_context(|| format!("read reference image {}", image_path.display()))?;
|
||||
let image_secret = imgsecret::extract(&image_bytes)
|
||||
.context("extract image secret")?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter vault passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
use qrcode::{EcLevel, QrCode, render::unicode};
|
||||
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
|
||||
.expect("valid payload");
|
||||
let image = code
|
||||
.render::<unicode::Dense1x2>()
|
||||
.dark_color(unicode::Dense1x2::Dark)
|
||||
.light_color(unicode::Dense1x2::Light)
|
||||
.build();
|
||||
println!("{image}");
|
||||
println!("Recovery QR generated. Print or photograph this code and store it securely.");
|
||||
println!("The QR has NOT been saved to disk.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_recovery_qr_unwrap() -> Result<()> {
|
||||
use relicario_core::unwrap_recovery_qr;
|
||||
use std::io::BufRead;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
println!("Paste the base64 recovery QR payload and press Enter:");
|
||||
let stdin = std::io::stdin();
|
||||
let payload_b64 = stdin.lock().lines().next()
|
||||
.context("no input")??;
|
||||
let payload_b64 = payload_b64.trim().to_owned();
|
||||
|
||||
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
|
||||
|
||||
let passphrase = Zeroizing::new(
|
||||
rpassword::prompt_password("Enter passphrase: ")
|
||||
.context("read passphrase")?
|
||||
);
|
||||
|
||||
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
println!("image_secret: {}", hex::encode(secret.as_ref()));
|
||||
Ok(())
|
||||
}
|
||||
98
crates/relicario-cli/src/commands/settings.rs
Normal file
98
crates/relicario-cli/src/commands/settings.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::SettingsAction;
|
||||
|
||||
pub fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||
use relicario_core::{
|
||||
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||
SymbolCharset, TrashRetention,
|
||||
};
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut settings = vault.load_settings()?;
|
||||
|
||||
match action {
|
||||
SettingsAction::Show => {
|
||||
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||
return Ok(());
|
||||
}
|
||||
SettingsAction::TrashRetention { days, forever } => {
|
||||
settings.trash_retention = match (days, forever) {
|
||||
(Some(d), false) => TrashRetention::Days(d),
|
||||
(None, true) => TrashRetention::Forever,
|
||||
_ => anyhow::bail!("specify exactly one of --days or --forever"),
|
||||
};
|
||||
}
|
||||
SettingsAction::HistoryRetention { last_n, days, forever } => {
|
||||
settings.field_history_retention = match (last_n, days, forever) {
|
||||
(Some(n), None, false) => HistoryRetention::LastN(n),
|
||||
(None, Some(d), false) => HistoryRetention::Days(d),
|
||||
(None, None, true) => HistoryRetention::Forever,
|
||||
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
|
||||
};
|
||||
}
|
||||
SettingsAction::AttachmentCap {
|
||||
per_attachment_max_bytes, per_item_max_count,
|
||||
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
|
||||
} => {
|
||||
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
|
||||
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
|
||||
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
|
||||
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
|
||||
}
|
||||
SettingsAction::GeneratorDefaults {
|
||||
random, bip39, length, words, symbols, separator,
|
||||
} => {
|
||||
// Decide target mode: explicit flag wins, else preserve current.
|
||||
let target_bip39 = if random { false }
|
||||
else if bip39 { true }
|
||||
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
|
||||
|
||||
// Pull existing fields where compatible, else seed with sensible
|
||||
// defaults (kept in sync with `GeneratorRequest::default()`).
|
||||
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
|
||||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||
(*length, *classes, symbol_charset.clone())
|
||||
}
|
||||
_ => (
|
||||
20,
|
||||
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||
SymbolCharset::SafeOnly,
|
||||
),
|
||||
};
|
||||
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
|
||||
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||
(*word_count, separator.clone(), *capitalization)
|
||||
}
|
||||
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||
};
|
||||
|
||||
settings.generator_defaults = if target_bip39 {
|
||||
GeneratorRequest::Bip39 {
|
||||
word_count: words.unwrap_or(cur_words),
|
||||
separator: separator.unwrap_or(cur_sep),
|
||||
capitalization: cur_cap,
|
||||
}
|
||||
} else {
|
||||
let charset = match symbols.as_deref() {
|
||||
None => cur_charset,
|
||||
Some("safe") => SymbolCharset::SafeOnly,
|
||||
Some("extended") => SymbolCharset::Extended,
|
||||
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||
};
|
||||
GeneratorRequest::Random {
|
||||
length: length.unwrap_or(cur_length),
|
||||
classes: cur_classes,
|
||||
symbol_charset: charset,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
vault.save_settings(&settings)?;
|
||||
super::commit_paths(&vault, "settings: update", &["settings.enc"])?;
|
||||
eprintln!("Settings updated.");
|
||||
Ok(())
|
||||
}
|
||||
52
crates/relicario-cli/src/commands/status.rs
Normal file
52
crates/relicario-cli/src/commands/status.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! `relicario status` — vault-level summary (counts, last commit, last backup).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_status() -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let root = vault.root().to_path_buf();
|
||||
let manifest = vault.load_manifest()?;
|
||||
|
||||
let total_items = manifest.items.len();
|
||||
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||
let active_items = total_items - trashed_items;
|
||||
|
||||
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||
.flat_map(|e| e.attachment_summaries.iter())
|
||||
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||
|
||||
let last_commit = crate::helpers::git_command(&root, &[
|
||||
"log", "-1", "--pretty=format:%h %s",
|
||||
]).output()
|
||||
.ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "(no commits)".into());
|
||||
|
||||
// Last backup age (read from marker written by cmd_backup_export).
|
||||
let last_backup_path = vault.root().join(".relicario").join("last_backup");
|
||||
let last_backup_str = if last_backup_path.exists() {
|
||||
let line = std::fs::read_to_string(&last_backup_path)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
// Parse the ISO-8601 we wrote in cmd_backup_export.
|
||||
match chrono::DateTime::parse_from_rfc3339(&line) {
|
||||
Ok(then) => {
|
||||
let now = relicario_core::now_unix();
|
||||
let age = now - then.timestamp();
|
||||
crate::helpers::humanize_age(age.max(0))
|
||||
}
|
||||
Err(_) => "unknown".to_string(),
|
||||
}
|
||||
} else {
|
||||
"never".to_string()
|
||||
};
|
||||
|
||||
println!("Vault: {}", root.display());
|
||||
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||
println!("Last commit: {last_commit}");
|
||||
println!("Last export: {last_backup_str}");
|
||||
Ok(())
|
||||
}
|
||||
11
crates/relicario-cli/src/commands/sync.rs
Normal file
11
crates/relicario-cli/src/commands/sync.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! `relicario sync` — pull --rebase + push.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn cmd_sync() -> Result<()> {
|
||||
let root = crate::helpers::vault_dir()?;
|
||||
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
|
||||
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
|
||||
eprintln!("Sync complete.");
|
||||
Ok(())
|
||||
}
|
||||
149
crates/relicario-cli/src/commands/trash.rs
Normal file
149
crates/relicario-cli/src/commands/trash.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent),
|
||||
//! `trash list` / `trash empty`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::TrashAction;
|
||||
|
||||
pub fn cmd_rm(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
item.soft_delete();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Moved to trash: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_restore(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let _ = entry;
|
||||
let mut item = vault.load_item(&id)?;
|
||||
item.restore();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Restored: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
|
||||
/// the manifest in memory. Returns the relative paths the caller must stage
|
||||
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
|
||||
/// batches them.
|
||||
pub(super) fn purge_item_filesystem(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
manifest: &mut relicario_core::Manifest,
|
||||
id: &relicario_core::ItemId,
|
||||
title: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
use std::{fs, io::ErrorKind};
|
||||
|
||||
let item_rel = format!("items/{}.enc", id.as_str());
|
||||
let att_rel = format!("attachments/{}", id.as_str());
|
||||
|
||||
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
|
||||
match r {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
};
|
||||
ignore_missing(fs::remove_file(vault.item_path(id)))?;
|
||||
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
|
||||
manifest.remove(id);
|
||||
|
||||
eprintln!("Purged: {title}");
|
||||
Ok(vec![item_rel, att_rel])
|
||||
}
|
||||
|
||||
pub fn cmd_purge(query: String) -> Result<()> {
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let id = entry.id.clone();
|
||||
let title = entry.title.clone();
|
||||
let _ = entry;
|
||||
|
||||
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["add", "manifest.enc"],
|
||||
&format!("{purge_ctx}: git add manifest.enc"),
|
||||
)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
||||
&format!("{purge_ctx}: git commit"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_trash(action: TrashAction) -> Result<()> {
|
||||
match action {
|
||||
TrashAction::List => super::list::cmd_list(None, None, None, true),
|
||||
TrashAction::Empty => cmd_trash_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cmd_trash_empty() -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
let settings = vault.load_settings()?;
|
||||
let now = now_unix();
|
||||
|
||||
let purgeable: Vec<_> = manifest.items.values()
|
||||
.filter(|e| match e.trashed_at {
|
||||
Some(t) => settings.trash_retention.should_purge(t, now),
|
||||
None => false,
|
||||
})
|
||||
.map(|e| (e.id.clone(), e.title.clone()))
|
||||
.collect();
|
||||
|
||||
if purgeable.is_empty() {
|
||||
eprintln!("nothing past retention window");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut all_paths: Vec<String> = Vec::new();
|
||||
let purged_count = purgeable.len();
|
||||
for (id, title) in purgeable {
|
||||
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
all_paths.append(&mut paths);
|
||||
}
|
||||
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["add", "manifest.enc"],
|
||||
"trash empty: git add manifest.enc",
|
||||
)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||
"trash empty: git commit",
|
||||
)?;
|
||||
|
||||
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||
Ok(())
|
||||
}
|
||||
@@ -55,6 +55,47 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
|
||||
/// capturing stdout/stderr and reproducing them on failure so the caller
|
||||
/// sees git's exact diagnostic instead of just a verb.
|
||||
///
|
||||
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
|
||||
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
|
||||
/// identifiable from the error alone.
|
||||
///
|
||||
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
|
||||
/// (so live progress disappears during long-running fetches/pushes) but the
|
||||
/// captured chunk is replayed verbatim on failure. The win is that
|
||||
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
|
||||
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
|
||||
/// instead of one-line "git X failed" bails. Use `git_command` directly when
|
||||
/// live streaming is required.
|
||||
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||
let output = git_command(repo, args)
|
||||
.output()
|
||||
.with_context(|| format!("{context}: failed to spawn git"))?;
|
||||
if !output.status.success() {
|
||||
if !output.stdout.is_empty() {
|
||||
eprint!("{}", String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
if !output.stderr.is_empty() {
|
||||
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
bail!("{context}: git failed ({})", output.status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||||
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||||
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||||
/// disk, and we want the rm to succeed regardless.
|
||||
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||||
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||||
args.extend(paths.iter().map(String::as_str));
|
||||
git_run(repo, &args, context)
|
||||
}
|
||||
|
||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||
/// a numeric string.
|
||||
@@ -95,6 +136,24 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
vault_dir.join(".relicario").join("groups.cache")
|
||||
}
|
||||
|
||||
/// Collect all non-empty group names from the manifest and write them to the
|
||||
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||
/// candidates without prompting for the vault passphrase.
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
if !g.is_empty() {
|
||||
set.insert(g.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = write_groups_cache(vault_dir, &set);
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||
/// suppresses the write (developer debugging tool). In release builds the env
|
||||
@@ -220,6 +279,24 @@ mod tests {
|
||||
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_run_bails_with_context_on_failure() {
|
||||
// Empty tempdir — `git status` will fail with "not a git repository".
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
|
||||
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_run_succeeds_for_a_zero_exit_command() {
|
||||
// `git --version` always succeeds and is independent of cwd.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
git_run(tmp.path(), &["--version"], "version probe")
|
||||
.expect("git --version should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_age_buckets() {
|
||||
assert_eq!(humanize_age(0), "just now");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
47
crates/relicario-cli/src/parse.rs
Normal file
47
crates/relicario-cli/src/parse.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
|
||||
//!
|
||||
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
|
||||
//! turns this file into a thin re-export shim. They live here for now so
|
||||
//! the Phase 1 relocation stays mechanical.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||
let month: u8 = m_str.parse().context("invalid month")?;
|
||||
let year: u16 = if y_str.len() == 2 {
|
||||
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
|
||||
} else {
|
||||
y_str.parse().context("invalid year")?
|
||||
};
|
||||
Ok(relicario_core::MonthYear { month, year })
|
||||
}
|
||||
|
||||
pub(crate) fn guess_mime(filename: &str) -> String {
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
|
||||
"pdf" => "application/pdf",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"txt" => "text/plain",
|
||||
"json" => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
||||
let cleaned: String = s.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase()
|
||||
.trim_end_matches('=')
|
||||
.to_string();
|
||||
let padded = {
|
||||
let rem = cleaned.len() % 8;
|
||||
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
|
||||
};
|
||||
data_encoding::BASE32.decode(padded.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
|
||||
}
|
||||
65
crates/relicario-cli/src/prompt.rs
Normal file
65
crates/relicario-cli/src/prompt.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Interactive prompt helpers for the CLI.
|
||||
//!
|
||||
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
|
||||
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
|
||||
//! used by the edit handlers to keep current values when the user hits enter
|
||||
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||
/// unavailable in assert_cmd-spawned children).
|
||||
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
||||
if let Some(s) = crate::test_item_secret_override() {
|
||||
return Ok(s);
|
||||
}
|
||||
rpassword::prompt_password(label).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||
eprint!("{label} [{current}]: ");
|
||||
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) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
|
||||
let display = current.unwrap_or("(none)");
|
||||
eprint!("{label} [{display}]: ");
|
||||
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) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
||||
eprint!("{label} [y/N] ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
||||
}
|
||||
@@ -69,9 +69,15 @@ impl UnlockedVault {
|
||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
||||
/// Save the manifest and refresh the plaintext groups.cache. This is the
|
||||
/// canonical "I just mutated the manifest" funnel — every command that
|
||||
/// changes the manifest goes through this method, so cache freshness is
|
||||
/// a compile-time invariant rather than a discipline rule.
|
||||
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
|
||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||
atomic_write(&self.manifest_path(), &bytes)
|
||||
atomic_write(&self.manifest_path(), &bytes)?;
|
||||
crate::helpers::refresh_groups_cache(&self.root, manifest);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||
@@ -107,17 +113,52 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ParamsFile {
|
||||
kdf: KdfParams,
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct ParamsFile {
|
||||
pub format_version: u32,
|
||||
pub kdf: ParamsKdf,
|
||||
pub aead: String,
|
||||
pub salt_path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) struct ParamsKdf {
|
||||
pub algorithm: String,
|
||||
pub argon2_m: u32,
|
||||
pub argon2_t: u32,
|
||||
pub argon2_p: u32,
|
||||
}
|
||||
|
||||
impl ParamsFile {
|
||||
pub fn for_new_vault(params: &KdfParams) -> Self {
|
||||
Self {
|
||||
format_version: 2,
|
||||
kdf: ParamsKdf {
|
||||
algorithm: "argon2id-v0x13".into(),
|
||||
argon2_m: params.argon2_m,
|
||||
argon2_t: params.argon2_t,
|
||||
argon2_p: params.argon2_p,
|
||||
},
|
||||
aead: "xchacha20poly1305".into(),
|
||||
salt_path: ".relicario/salt".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_kdf_params(&self) -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: self.kdf.argon2_m,
|
||||
argon2_t: self.kdf.argon2_t,
|
||||
argon2_p: self.kdf.argon2_p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.context("failed to read .relicario/params.json")?;
|
||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||
Ok(pf.kdf)
|
||||
Ok(pf.to_kdf_params())
|
||||
}
|
||||
|
||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||
@@ -149,3 +190,78 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"format_version": 2,
|
||||
"kdf": {
|
||||
"algorithm": "argon2id-v0x13",
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
},
|
||||
"aead": "xchacha20poly1305",
|
||||
"salt_path": ".relicario/salt"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn params_file_round_trips_current_layout() {
|
||||
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
|
||||
assert_eq!(pf.format_version, 2);
|
||||
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
|
||||
assert_eq!(pf.kdf.argon2_m, 65536);
|
||||
assert_eq!(pf.kdf.argon2_t, 3);
|
||||
assert_eq!(pf.kdf.argon2_p, 4);
|
||||
assert_eq!(pf.aead, "xchacha20poly1305");
|
||||
assert_eq!(pf.salt_path, ".relicario/salt");
|
||||
|
||||
let kdf = pf.to_kdf_params();
|
||||
assert_eq!(kdf.argon2_m, 65536);
|
||||
assert_eq!(kdf.argon2_t, 3);
|
||||
assert_eq!(kdf.argon2_p, 4);
|
||||
|
||||
let serialized = serde_json::to_string(&pf).expect("re-serialize");
|
||||
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
|
||||
assert_eq!(pf2.format_version, 2);
|
||||
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
|
||||
assert_eq!(pf2.kdf.argon2_m, 65536);
|
||||
assert_eq!(pf2.kdf.argon2_t, 3);
|
||||
assert_eq!(pf2.kdf.argon2_p, 4);
|
||||
assert_eq!(pf2.aead, "xchacha20poly1305");
|
||||
assert_eq!(pf2.salt_path, ".relicario/salt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_new_vault_produces_expected_shape() {
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
let pf = ParamsFile::for_new_vault(¶ms);
|
||||
let v = serde_json::to_value(&pf).expect("to_value");
|
||||
assert_eq!(v["format_version"], 2);
|
||||
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
|
||||
assert_eq!(v["kdf"]["argon2_m"], 65536);
|
||||
assert_eq!(v["kdf"]["argon2_t"], 3);
|
||||
assert_eq!(v["kdf"]["argon2_p"], 4);
|
||||
assert_eq!(v["aead"], "xchacha20poly1305");
|
||||
assert_eq!(v["salt_path"], ".relicario/salt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_manifest_change_writes_manifest_and_groups_cache() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let root = dir.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join(".relicario")).unwrap();
|
||||
std::fs::create_dir_all(root.join("items")).unwrap();
|
||||
let vault = UnlockedVault {
|
||||
root: root.clone(),
|
||||
master_key: Zeroizing::new([0u8; 32]),
|
||||
};
|
||||
let manifest = Manifest::new();
|
||||
|
||||
vault.after_manifest_change(&manifest).unwrap();
|
||||
assert!(root.join("manifest.enc").exists());
|
||||
assert!(root.join(".relicario/groups.cache").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,72 @@ fn rm_restore_purge_cycle() {
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_empty_batches_into_one_commit() {
|
||||
let v = TestVault::init();
|
||||
|
||||
// Add 3 items.
|
||||
for title in ["alpha", "bravo", "charlie"] {
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", title,
|
||||
"--username", "u",
|
||||
"--password", "p",
|
||||
]);
|
||||
assert!(out.status.success(), "add {title} failed");
|
||||
}
|
||||
|
||||
// Soft-delete all 3.
|
||||
for title in ["alpha", "bravo", "charlie"] {
|
||||
let out = v.run(&["rm", title]);
|
||||
assert!(out.status.success(), "rm {title} failed");
|
||||
}
|
||||
|
||||
// Set retention to 0 days so the recently-trashed items become purgeable
|
||||
// (should_purge: now - trashed_at > 0 * 86400 = 0).
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
|
||||
assert!(out.status.success(), "settings trash-retention failed");
|
||||
|
||||
// should_purge uses strict > on (now - trashed_at), so equal-second
|
||||
// timestamps don't qualify.
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
// Count commits before.
|
||||
let before = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.current_dir(v.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||
|
||||
// Run trash empty.
|
||||
let out = v.run(&["trash", "empty"]);
|
||||
assert!(out.status.success(), "trash empty failed: stderr={}",
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Count commits after.
|
||||
let after = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.current_dir(v.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_count - before_count, 1,
|
||||
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
|
||||
);
|
||||
|
||||
// The remaining `list --trashed` should be empty.
|
||||
let out = v.run(&["list", "--trashed"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(
|
||||
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
|
||||
"items still in trashed list: stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_and_bip39() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# CLI Tail — Cycle 2 Coordinator
|
||||
|
||||
**Date:** 2026-05-09
|
||||
**Status:** Draft (launches once cycle-1 prerequisites land)
|
||||
**Theme:** parallelize the post-split tail of Plan B (the CLI restructure) across three independent streams. Plan B's eight phases are already defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`; this coordinator only partitions the remaining phases across cycle-2 streams and records the cross-stream contracts.
|
||||
|
||||
## What this is
|
||||
|
||||
The cycle-1 four-agent run (`2026-05-04-arch-followup-*`) ships:
|
||||
|
||||
- **Stream A** — Plan A (security + docs polish): `impl Drop for SessionHandle`, JS swallow removal, `recovery_qr.rs` docs, `start.sh` fourth-window. Independent of B and C.
|
||||
- **Stream B** — Plan B Phases 1 + 2 only (mechanical `main.rs` split + `helpers::git_run` + 16-site sweep). Stops after Phase 2 per a 2026-05-09 user-driven RESCOPE directive.
|
||||
- **Stream C** — Plan C (extension restructure). Did not launch in cycle 1 (DEV-C never acked); remains pending and is *not* picked up by cycle 2 (still its own multi-week effort, separate kickoff).
|
||||
|
||||
The remaining six Plan B phases (3 through 8) are partitioned across three cycle-2 streams below. Each cycle-2 stream is independent of the other two once cycle-1 Stream B (Phase 1 + 2) has merged to `main`.
|
||||
|
||||
## Pre-launch checklist (cycle 2 cannot open until all green)
|
||||
|
||||
- [ ] Cycle-1 Stream A merged to `main`
|
||||
- [ ] Cycle-1 Stream B PR (Phase 1 + 2 bundle) merged to `main`
|
||||
- [ ] Working tree clean on `main`; `git pull` reflects both merges
|
||||
- [ ] All cycle-1 worktrees torn down (`git worktree remove ../relicario.arch-followup-stream-a` and `*-stream-b`); cycle-1 branches deleted locally if requested
|
||||
- [ ] Relay server still running on `localhost:7331` (check `ss -ltn 'sport = :7331'`)
|
||||
- [ ] Cycle-2 kickoff prompts present in `docs/superpowers/coordination/2026-05-09-cli-tail-{pm,dev-a,dev-b,dev-c}-prompt.md`
|
||||
|
||||
## Stream partition
|
||||
|
||||
| Stream | Branch | Worktree | Plan B phases | Theme |
|
||||
|---|---|---|---|---|
|
||||
| A | `feature/cli-tail-stream-a-prompt-helpers` | `/home/alee/Sources/relicario.cli-tail-stream-a` | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
|
||||
| B | `feature/cli-tail-stream-b-session-manifest` | `/home/alee/Sources/relicario.cli-tail-stream-b` | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
|
||||
| C | `feature/cli-tail-stream-c-core-wasm-seam` | `/home/alee/Sources/relicario.cli-tail-stream-c` | Phases 7, 8 | parser migration to `relicario-core` + base32 dedup + WASM exports |
|
||||
|
||||
Phases reference the canonical definitions in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md`. Devs do NOT redesign — they execute against that spec.
|
||||
|
||||
## Cross-stream dependencies (cycle 2)
|
||||
|
||||
- **Stream A and Stream B**: both touch `crates/relicario-cli/src/commands/*.rs` files but in disjoint ways. Stream A modifies `commands/add.rs` (the seven `build_*_item` builders). Stream B modifies `commands/init.rs` (`ParamsFile`), `commands/trash.rs` (batched purge), and seven manifest-mutation sites scattered across `commands/{add,edit,trash,attach,settings,import}.rs`. Conflict surface is `commands/add.rs` (A modifies builders; B modifies the `after_manifest_change` callsite). Whoever opens their PR second rebases.
|
||||
- **Stream B internal sequencing**: Phase 6 (batched purge) depends on Phase 4 (`after_manifest_change` wrapper) — Phase 6's commit message logic uses the wrapper. Phase 5 (`ParamsFile`) is independent of 4 and 6 within Stream B; can ship first, last, or middle.
|
||||
- **Stream C**: touches `crates/relicario-core/`, `crates/relicario-wasm/`, and `extension/src/wasm.d.ts` only. Zero overlap with Streams A and B. Internal sequencing: Phase 7 (parser migration to core) before Phase 8 (WASM exports + `wasm.d.ts` mirror).
|
||||
- **No cross-stream interface contracts.** All three plans were finalized in cycle 1; the partition does not introduce new contracts.
|
||||
|
||||
## Pre-merge checklist (per cycle-2 stream)
|
||||
|
||||
Same as cycle 1, plus a narration check:
|
||||
|
||||
- [ ] Stream's owned phases all complete per Plan B's "Done criteria"
|
||||
- [ ] `cargo test --workspace` green on the stream's worktree
|
||||
- [ ] `cargo clippy --workspace` silent
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, but Stream C in particular)
|
||||
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` tests pass without modification
|
||||
- [ ] Narration discipline observed — STATUS UPDATEs include in-flight beats, not just phase boundaries
|
||||
- [ ] PR description cross-references the corresponding Plan B phase numbers
|
||||
|
||||
## Out of scope for cycle 2
|
||||
|
||||
- Plan C (extension restructure) — multi-week effort, scheduled separately when DEV-C bandwidth available
|
||||
- The Plan B `helpers::git_run` itself (shipped in cycle 1 Stream B)
|
||||
- The cycle-1 P3 nits explicitly out-of-scope in Plan B
|
||||
- The eight "Open architectural decisions" from the synthesis
|
||||
|
||||
## Tag
|
||||
|
||||
No release tag for cycle 2. Same as the cycle-1 architecture-review followup train — these are structural-cleanup bundles, not versioned releases. Each stream merges via `gh pr merge --merge` (preserve history; no squash per project convention).
|
||||
@@ -0,0 +1,199 @@
|
||||
# Dev A Kickoff Prompt — CLI Tail (Cycle 2) Stream A
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream A of the CLI-tail cycle-2 release.
|
||||
|
||||
Stream A is **Plan B Phase 3** — `prompt_or_flag<T>` helper plus the seven `build_*_item` builder compression in the CLI. Single phase, S-M effort. The phase is defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 3 — `prompt_or_flag<T>` and `build_*_item` compression". Cycle 1 already shipped the mechanical `main.rs` split (Phase 1) and the `helpers::git_run` sweep (Phase 2), so the file tree under `crates/relicario-cli/src/commands/` and `prompt.rs` is in place — your job is to add the helper to `prompt.rs` and refactor the seven builders in `commands/add.rs`.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-B (session/manifest discipline — Phases 4, 5, 6) and Dev-C (parser migration + WASM seam — Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.cli-tail-stream-a -b feature/cli-tail-stream-a-prompt-helpers
|
||||
cd ../relicario.cli-tail-stream-a
|
||||
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-a
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-a`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-a` so subagents don't accidentally commit to main. This is non-negotiable.
|
||||
|
||||
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-a"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-a"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-a")`. After emitting any status/question block: `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-a","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-a"}'
|
||||
```
|
||||
|
||||
**Cycle-1 lessons baked in (read once):**
|
||||
|
||||
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phase 3 only
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (your scope is **Phase 3 only**; read the whole plan for context, but execute Phase 3)
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the `build_*_item` discussion (line-level context for the seven builders the synthesis abbreviates)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development** (per `CLAUDE.md` memory default for any multi-task plan). Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per sub-step, two-stage review.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-a
|
||||
```
|
||||
|
||||
…before any other instruction. Non-negotiable per project memory.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Plan B Phase 3 — adding `prompt_or_flag<T>` (and `prompt_or_flag_optional<T>`) to `crates/relicario-cli/src/prompt.rs`, then refactoring the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper. Per-type bodies should shrink by ~30%.
|
||||
|
||||
**Out of scope:**
|
||||
- Phases 4, 5, 6 (Dev-B owns) — `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge
|
||||
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
|
||||
- Anything outside Plan B's Phase 3 definition. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Do not change the CLI's external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The user runs all four; the PM in another terminal coordinates you.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||
|
||||
- When you dispatch a subagent (so the user sees what's running)
|
||||
- When a subagent returns with a decision worth flagging (an unexpected finding, a trade-off taken, a surprise)
|
||||
- When a sub-task completes (e.g. `prompt_or_flag` helper landed; first builder converted)
|
||||
- When you change direction or hit something unexpected
|
||||
- When you start a new sub-step
|
||||
|
||||
The `Notes` field should narrate WHAT happened and WHY — not just "Phase 3 done". Three sentences max. Examples of useful: "subagent reported `build_login_item` already takes Result-wrapped fields, so the conversion is just chain-flattening"; "found one builder uses `prompt_secret`, kept it on raw `prompt_secret` since `prompt_or_flag` doesn't handle the no-echo case." Examples of NOT useful: "builder converted" with no context; "tests pass" with no count.
|
||||
|
||||
Print every STATUS UPDATE locally before/after sending it so the user reads it in your own terminal.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-a")` first, then post via `post_message(from="dev-a", to="pm", kind="status"|"question", body="...")` and also print here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-A
|
||||
Time: <iso8601>
|
||||
Branch: feature/cli-tail-stream-a-prompt-helpers
|
||||
Task: <number / short name>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line of message>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <WHAT and WHY — 3 sentences max>
|
||||
```
|
||||
|
||||
**When you need PM input mid-task**: post via `post_message(kind="question")`:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-A
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
**You'll receive**: `## DIRECTIVE TO DEV-A` blocks from the PM.
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within Phase 3
|
||||
|
||||
You don't need PM permission to:
|
||||
|
||||
- Execute sub-steps per Plan B's Phase 3
|
||||
- Make implementation decisions consistent with Plan B
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate when:
|
||||
|
||||
- A scope question outside Plan B Phase 3
|
||||
- A test you can't make green after honest debugging
|
||||
- A discovered bug not in Plan B
|
||||
- Anything destructive (per `CLAUDE.md`)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-a
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
All three must be green / clean. Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/cli-tail-stream-a-prompt-helpers
|
||||
gh pr create --base main --head feature/cli-tail-stream-a-prompt-helpers --title "refactor(cli): prompt_or_flag helper + build_*_item compression (Plan B Phase 3)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Adds `prompt_or_flag<T>` and `prompt_or_flag_optional<T>` to `crates/relicario-cli/src/prompt.rs`
|
||||
- Refactors the seven `build_*_item` functions in `crates/relicario-cli/src/commands/add.rs` to use the helper
|
||||
- Per-type bodies shrink by ~30%; existing CLI integration tests pass without modification
|
||||
|
||||
## Plan B Phase 3
|
||||
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phase 3.
|
||||
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test --workspace
|
||||
- [x] cargo clippy --workspace
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-a-prompt-helpers`), then start Phase 3 sub-step 1 (add `prompt_or_flag<T>` to `prompt.rs`).
|
||||
@@ -0,0 +1,210 @@
|
||||
# Dev B Kickoff Prompt — CLI Tail (Cycle 2) Stream B
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream B of the CLI-tail cycle-2 release.
|
||||
|
||||
Stream B is **Plan B Phases 4, 5, 6** — session/manifest discipline. Three phases, S-M effort each, total mid-day to multi-day:
|
||||
|
||||
- **Phase 4** — `Vault::after_manifest_change(&self, manifest: &Manifest)` wrapper that funnels the seven manifest-mutation sites in `commands/{add,edit,trash,attach,settings,import}.rs` through one `save_manifest + groups-cache write` path. Marks `save_manifest` as `pub(crate)` (or renames it `save_manifest_raw`) so callers must use the wrapper.
|
||||
- **Phase 5** — Single canonical `ParamsFile` in `crates/relicario-cli/src/session.rs`, replacing the two-definition split between `commands/init.rs` (write side) and `session.rs:114` (read side). Adds `Serialize` + `Deserialize`, `for_new_vault` constructor, `into_kdf_params` inversion. On-disk JSON format must round-trip with current `params.json` files.
|
||||
- **Phase 6** — Batched purge in `cmd_purge` and `cmd_trash_empty`. Renames `purge_item` to `purge_item_filesystem` (filesystem mutation only); the callers accumulate paths and run a single `git_run(...["rm", "-rf", "--ignore-unmatch", paths...])` plus `git_run(...["add", "manifest.enc"])` plus one `git_run(...["commit"])` per batch. A 50-item `trash empty` should fire 3 git invocations total, not 150.
|
||||
|
||||
The phases are defined in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` under "Phase 4", "Phase 5", "Phase 6". Internal sequencing: Phase 4 before Phase 6 (Phase 6 uses `after_manifest_change`); Phase 5 is independent of 4 and 6.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-C (Plan B Phases 7, 8). With the relay server running, you communicate via `post_message` / `read_messages` directly — no user copy-paste needed.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.cli-tail-stream-b -b feature/cli-tail-stream-b-session-manifest
|
||||
cd ../relicario.cli-tail-stream-b
|
||||
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-b
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-b`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-b` so subagents don't accidentally commit to main. Non-negotiable.
|
||||
|
||||
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-b"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-b"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-b")`. After emitting any status/question block: `post_message(from="dev-b", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-b","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-b"}'
|
||||
```
|
||||
|
||||
**Cycle-1 lessons baked in (read once):**
|
||||
|
||||
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 dev-a and dev-b both hit this; documenting once here.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 4, 5, 6 only
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 4, 5, 6)
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes; the relevant sections are `refresh_groups_cache` discipline, `ParamsFile` dedup, batched purge
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review between phases.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-b
|
||||
```
|
||||
|
||||
…before any other instruction.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Plan B Phases 4, 5, 6.
|
||||
|
||||
**Out of scope:**
|
||||
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + `build_*_item` compression
|
||||
- Phases 7, 8 (Dev-C owns) — parser migration to `relicario-core`, base32 dedup, WASM exports
|
||||
- Anything outside Plan B Phases 4-6. If you trip over an out-of-scope issue, file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Phase 5 must round-trip with existing on-disk `params.json` — write a fixture-string test that reads a known-current params.json and asserts the canonical struct parses it identically. On-disk format change would break existing vaults.
|
||||
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||
- The `groups.cache` plaintext "failures silently swallowed" doc-comment from current `helpers.rs:90-93` must be preserved on the new `after_manifest_change` wrapper. Don't change the policy.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
**Internal phase sequencing (within Stream B):**
|
||||
- Phase 5 (`ParamsFile`) is independent — ship first to get it out of the way, OR last for diff-locality with the session-touching Phase 4. Either is fine; pick whichever reviews more cleanly.
|
||||
- Phase 4 (`after_manifest_change`) before Phase 6 (`batched purge`). Phase 6's commit logic relies on the wrapper.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The PM coordinates you with Dev-A and Dev-C.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||
|
||||
- When you dispatch a subagent
|
||||
- When a subagent returns with a decision worth flagging (a found-but-unexpected coupling, a trade-off taken)
|
||||
- When a sub-task completes (e.g. `after_manifest_change` wrapper landed; first manifest-mutation site converted; `ParamsFile` round-trip test green)
|
||||
- When you change direction or hit something unexpected
|
||||
- When you start a new phase
|
||||
|
||||
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "Phase 5 fixture test caught that `format_version` was previously emitted but never read; preserved the field but kept the read side tolerant"; "found one manifest-mutation site in `commands/import.rs` that did NOT call `refresh_groups_cache` historically (DEV-B notes flagged 7 sites; this is an 8th — surfacing as a question)." Print every STATUS UPDATE locally too.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-b")`, then post and print using:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-B
|
||||
Time: <iso8601>
|
||||
Branch: feature/cli-tail-stream-b-session-manifest
|
||||
Task: <phase number / sub-step>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <WHAT and WHY — 3 sentences max>
|
||||
```
|
||||
|
||||
**For PM input mid-task**:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-B
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within Phases 4-6
|
||||
|
||||
You don't need PM permission to:
|
||||
|
||||
- Execute sub-steps per Plan B's Phases 4, 5, 6
|
||||
- Make implementation decisions consistent with Plan B
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate when:
|
||||
|
||||
- A scope question outside Plan B Phases 4-6
|
||||
- A test you can't make green after honest debugging
|
||||
- A discovered bug not in Plan B
|
||||
- Anything destructive (per `CLAUDE.md`)
|
||||
- Before opening the PR for review
|
||||
- If you find an unexpected manifest-mutation site beyond the seven DEV-B notes flagged (likely surfaces in Phase 4)
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-b
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
All three must be green / clean. Then push and open the PR:
|
||||
|
||||
```bash
|
||||
git push -u origin feature/cli-tail-stream-b-session-manifest
|
||||
gh pr create --base main --head feature/cli-tail-stream-b-session-manifest --title "refactor(cli): session/manifest discipline (Plan B Phases 4, 5, 6)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Phase 4 — `Vault::after_manifest_change` wrapper funnels seven manifest-mutation sites; `save_manifest` made `pub(crate)` so callers can't bypass the wrapper
|
||||
- Phase 5 — Single canonical `ParamsFile` in `session.rs` replaces the two-definition split; on-disk JSON round-trips with existing vaults (fixture-string test)
|
||||
- Phase 6 — Batched purge: a 50-item `trash empty` now fires 3 git invocations instead of 150
|
||||
|
||||
## Plan B Phases 4-6
|
||||
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 4, 5, 6.
|
||||
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test --workspace
|
||||
- [x] cargo clippy --workspace
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] params.json round-trip test against existing on-disk format
|
||||
- [x] `trash empty` with N items produces 1 commit (regression invariant)
|
||||
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-b-session-manifest`), then start Phase 4 (or Phase 5 if you prefer to ship the independent piece first — call it out in the status update).
|
||||
@@ -0,0 +1,219 @@
|
||||
# Dev C Kickoff Prompt — CLI Tail (Cycle 2) Stream C
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are a **senior developer** owning Stream C of the CLI-tail cycle-2 release.
|
||||
|
||||
Stream C is **Plan B Phases 7 and 8** — the parser migration to `relicario-core` plus the WASM seam. Two phases, M effort:
|
||||
|
||||
- **Phase 7** — Migrate `parse_month_year`, `base32_decode_lenient`, `guess_mime` from `crates/relicario-cli/src/parse.rs` into `relicario-core` (`MonthYear::parse` on `time.rs`, new `pub(crate) mod base32` with `encode_rfc4648` / `decode_rfc4648_lenient`, new `mime::guess_for_extension`). Pair with DEV-A's P2 base32 dedup: extract the inline `base32_encode` from `crates/relicario-core/src/item.rs:255-275` and `decode_base32_totp` from `crates/relicario-core/src/import_lastpass.rs:202-220` into the new shared module. Steam's `STEAM_ALPHABET` at `item_types/totp.rs:13` stays untouched (with a neighbour comment). The CLI's `parse.rs` becomes a thin re-export shim — no callsite changes in cycle 2.
|
||||
- **Phase 8** — `#[wasm_bindgen]` exports for the three migrated parsers (`parse_month_year`, `base32_decode_lenient`, `guess_mime`) plus the matching declarations in `extension/src/wasm.d.ts`. snake_case JS naming consistent with every existing export. Plan C (extension restructure) does NOT consume these this round — the seam ships in cycle 2; consumption is a future plan.
|
||||
|
||||
Phase definitions are canonical in `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8. Internal sequencing: Phase 7 before Phase 8.
|
||||
|
||||
A PM in another terminal coordinates you with Dev-A (Plan B Phase 3) and Dev-B (Plan B Phases 4, 5, 6). With the relay server running, you communicate via `post_message` / `read_messages` directly.
|
||||
|
||||
## Setup (do this first)
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario
|
||||
git fetch
|
||||
git checkout main
|
||||
git pull
|
||||
git worktree add ../relicario.cli-tail-stream-c -b feature/cli-tail-stream-c-core-wasm-seam
|
||||
cd ../relicario.cli-tail-stream-c
|
||||
pwd # should print /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
```
|
||||
|
||||
**ALL subsequent work happens in `/home/alee/Sources/relicario.cli-tail-stream-c`**. Force-cd subagents into this directory — `CLAUDE.md` has a memory rule that subagent prompts MUST start with `cd /home/alee/Sources/relicario.cli-tail-stream-c` so subagents don't accidentally commit to main. Non-negotiable.
|
||||
|
||||
Today: 2026-05-09. Project rules in `CLAUDE.md` apply.
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"dev-c"`
|
||||
- `read_messages(for)` — drain your inbox; call with `for="dev-c"` before each task
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="dev-c")`. After emitting any status/question block: `post_message(from="dev-c", to="pm", kind="status"|"question", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session, use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"dev-c","to":"pm","kind":"status","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"dev-c"}'
|
||||
```
|
||||
|
||||
**Cycle-1 lessons baked in (read once):**
|
||||
|
||||
- **Prefer single-line `body` content** when posting to the relay. Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals. Use periods between sentences and ` -- ` for stronger breaks; reserve actual newlines for STATUS UPDATEs you're printing locally only.
|
||||
- **If you build your own inbox-monitor in Python**: f-strings cannot contain backslash-escaped quotes inside brace expressions. Use single quotes inside: `{m.get('from')}` not `{m.get(\"from\")}`. Cycle-1 DEV-A and DEV-B both hit this; documenting once here so cycle-2 DEV-C does not.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — partition spec; confirms your scope is Phases 7 + 8 only
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (read the whole plan; execute Phases 7 and 8)
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — synthesis (skim only — your work is fully captured in Plan B)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-a-notes.md` — DEV-A's notes; the relevant section is the P2 "three base32 implementations" finding (the dedup that pairs with your Phase 7)
|
||||
6. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's notes; the relevant section is the parser-migration P2 (line-level context for `parse_month_year`, `base32_decode_lenient`, `guess_mime`)
|
||||
7. `docs/superpowers/reviews/2026-05-04-dev-c-notes.md` — read **only** the "Boundary notes for DEV-B" section (cross-boundary contracts — `wasm.d.ts` is hand-maintained; every change must mirror; BigInt typing care for `attachment_encrypt`-style paths, but your three new exports take only `&str` and return primitives so they avoid that class)
|
||||
|
||||
## Execution mode
|
||||
|
||||
Use **subagent-driven-development**. Invoke `superpowers:subagent-driven-development` and follow it: fresh subagent per phase, two-stage review.
|
||||
|
||||
**Every subagent prompt MUST start with**:
|
||||
|
||||
```
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
```
|
||||
|
||||
…before any other instruction.
|
||||
|
||||
## Your scope and boundaries
|
||||
|
||||
**In scope:** Plan B Phases 7 and 8 — parser migration to `relicario-core` (paired with DEV-A P2 base32 dedup), then WASM exports + `extension/src/wasm.d.ts` mirror.
|
||||
|
||||
**Out of scope:**
|
||||
- Phase 3 (Dev-A owns) — `prompt_or_flag<T>` + builder compression
|
||||
- Phases 4, 5, 6 (Dev-B owns) — session/manifest discipline
|
||||
- Plan C (extension restructure) — consumption of your new WASM exports is explicitly deferred to a future plan; you ship the seam, you do NOT wire SW message handlers in the extension.
|
||||
- Anything outside Plan B Phases 7-8. If you trip over an out-of-scope issue (e.g. a fourth base32 implementation surfaces; a parser the CLI uses that wasn't in Plan B's three), file a `## QUESTION TO PM` block and keep moving.
|
||||
|
||||
**Hard rules:**
|
||||
- Steam's `STEAM_ALPHABET` at `crates/relicario-core/src/item_types/totp.rs:13` is intentionally non-RFC-4648; do NOT consolidate it into the new shared base32 module. Add a neighbour comment: `// not RFC 4648 — Steam Guard's de-ambiguated alphabet; see crate::base32 for the standard impl.`
|
||||
- The CLI's `parse.rs` becomes a thin re-export shim — keep callsite imports unchanged in cycle 2 (no caller-side import churn).
|
||||
- WASM JS naming stays snake_case for the three new exports — consistent with every existing `#[wasm_bindgen]` export. Do NOT introduce camelCase here; that decision is explicitly deferred per Plan B.
|
||||
- `extension/src/wasm.d.ts` mirror lands in the same commit as the Rust `#[wasm_bindgen]` additions. Both sides updated together; no half-state.
|
||||
- Do not change CLI external behaviour — all existing `crates/relicario-cli/tests/*` integration tests must pass without modification.
|
||||
- Do not merge your branch to main. The PM owns merges.
|
||||
- Do not push `--force` or run `git reset --hard`. Per `CLAUDE.md`: ask first.
|
||||
|
||||
**Internal phase sequencing (within Stream C):**
|
||||
- Phase 7 (parser migration to core + base32 dedup) before Phase 8 (WASM exports). Phase 8 imports from the new core paths; Phase 7 must compile clean first.
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. The PM coordinates you with Dev-A and Dev-B.
|
||||
|
||||
**Narration discipline.** STATUS UPDATEs at task boundaries are the floor, not the ceiling. Also emit `Status: IN-PROGRESS` updates at meaningful in-flight moments:
|
||||
|
||||
- When you dispatch a subagent
|
||||
- When a subagent returns with a decision worth flagging (an unexpected coupling, an alternative API shape considered, a found-but-flagged out-of-scope issue)
|
||||
- When a sub-task completes (e.g. base32 module landed; `MonthYear::parse` integrated; first WASM export wired)
|
||||
- When you change direction or hit something unexpected
|
||||
- When you start a new phase
|
||||
|
||||
The `Notes` field should narrate WHAT and WHY. Three sentences max. Examples of useful: "subagent surfaced a fourth base32 callsite in `crates/relicario-core/src/manifest.rs:??`; not in DEV-A P2's flagged list — escalating as a question"; "kept `MonthYear::parse` returning `Result<Self, RelicarioError>` rather than touching `MonthYear::new`'s `&'static str` per Plan B's recommendation; `new`-to-`RelicarioError` is DEV-A's separate P3"; "WASM exports compile clean; `wasm.d.ts` mirror passes `tsc --noEmit` in `extension/`." Print every STATUS UPDATE locally too.
|
||||
|
||||
**At every task boundary AND every meaningful in-flight moment**: call `read_messages(for="dev-c")` first, then post via `post_message` and print here. Format:
|
||||
|
||||
```
|
||||
## STATUS UPDATE — DEV-C
|
||||
Time: <iso8601>
|
||||
Branch: feature/cli-tail-stream-c-core-wasm-seam
|
||||
Task: <phase number / sub-step>
|
||||
Status: STARTED | IN-PROGRESS | DONE | BLOCKED | REVIEW-READY
|
||||
Last commit: <short sha + first line>
|
||||
Tests: <green | red (which failed) | N/A>
|
||||
Notes: <WHAT and WHY — 3 sentences max>
|
||||
```
|
||||
|
||||
**For PM input mid-task**:
|
||||
|
||||
```
|
||||
## QUESTION TO PM — DEV-C
|
||||
Time: <iso8601>
|
||||
Context: <what task, what decision point>
|
||||
Options: <A: ... / B: ... / C: ...>
|
||||
Recommended: <your pick + one-sentence rationale>
|
||||
Blocker: yes | no
|
||||
```
|
||||
|
||||
## Ship-it autonomy + simplify discipline
|
||||
|
||||
The project's `.claude/settings.json` allows you to write files, run cargo/npm/bun/python3, commit, push, and open PRs without confirmation prompts. Move at speed.
|
||||
|
||||
**Hard guardrails (the deny list blocks these — never bypass with workarounds):** no `rm` / `rmdir`, no `git push --force` / `--force-with-lease`, no `git reset --hard`, no `git branch -D`, no `git worktree remove`, no `git clean -f*`, no `git checkout -- *`, no `git restore --source*`, no `sudo`, no `chmod 777`, no database drops. If you genuinely need one of these, surface a `## QUESTION TO PM` block.
|
||||
|
||||
**Speed without spaghetti — required before every REVIEW-READY:**
|
||||
|
||||
- Invoke `superpowers:simplify` on the changed code (it reviews for duplicate logic, missed reuse, gratuitous abstraction, half-finished implementations). Either accept its findings (and fix in the same commit) or surface a one-sentence rationale in the STATUS UPDATE Notes for why a flagged issue is intentional.
|
||||
- Do not create parallel implementations of an existing helper. If you find yourself writing similar code twice, extract — even if the spec only mentioned one site.
|
||||
- Do not add error handling, fallbacks, or validation for scenarios that can't happen (`CLAUDE.md` rule). Trust internal code and framework guarantees.
|
||||
- Default to no comments unless the WHY is non-obvious (`CLAUDE.md` rule). Don't explain WHAT well-named code already does.
|
||||
- Half-finished implementations are forbidden. Either ship a complete sub-task or surface a `## QUESTION TO PM` block.
|
||||
|
||||
## Authority within Phases 7-8
|
||||
|
||||
You don't need PM permission to:
|
||||
|
||||
- Execute sub-steps per Plan B's Phases 7 and 8
|
||||
- Make implementation decisions consistent with Plan B
|
||||
- Write tests, refactor your own code, fix bugs you introduce
|
||||
- Push commits to your feature branch
|
||||
|
||||
You **do** escalate when:
|
||||
|
||||
- A scope question outside Plan B Phases 7-8
|
||||
- A test you can't make green after honest debugging
|
||||
- A discovered bug not in Plan B
|
||||
- A fourth base32 implementation or a parser surfaces beyond DEV-A P2 + Plan B's three
|
||||
- Anything destructive (per `CLAUDE.md`)
|
||||
- Before opening the PR for review
|
||||
|
||||
## Final steps before REVIEW-READY
|
||||
|
||||
Run the project's full validation:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace
|
||||
cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
cd extension && npm run test # verify wasm.d.ts mirror compiles against TS callers
|
||||
```
|
||||
|
||||
All four must be green / clean. Then push and open the PR:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario.cli-tail-stream-c
|
||||
git push -u origin feature/cli-tail-stream-c-core-wasm-seam
|
||||
gh pr create --base main --head feature/cli-tail-stream-c-core-wasm-seam --title "refactor(core,wasm): migrate parsers + base32 dedup + WASM exports (Plan B Phases 7, 8)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Phase 7 — `parse_month_year`, `base32_decode_lenient`, `guess_mime` migrated from CLI to `relicario-core` (`MonthYear::parse`, new `pub(crate) mod base32`, new `mime::guess_for_extension`); base32 dedup folds `crates/relicario-core/src/item.rs:255-275` and `import_lastpass.rs:202-220` into the new shared module (Steam alphabet untouched per neighbour comment)
|
||||
- Phase 8 — `#[wasm_bindgen]` exports for the three migrated parsers; `extension/src/wasm.d.ts` mirror updated in the same commit; snake_case JS naming consistent with existing exports
|
||||
- The CLI's `parse.rs` is a thin re-export shim; existing CLI callsites unchanged
|
||||
|
||||
## Plan B Phases 7-8
|
||||
Implements `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` Phases 7 and 8.
|
||||
See `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` for cycle-2 partition.
|
||||
|
||||
## Test plan
|
||||
- [x] cargo test --workspace
|
||||
- [x] cargo clippy --workspace
|
||||
- [x] cargo build -p relicario-wasm --target wasm32-unknown-unknown
|
||||
- [x] cd extension && npm run test (verifies wasm.d.ts compiles)
|
||||
- [x] Existing crates/relicario-cli/tests/* pass without modification
|
||||
- [x] Existing crates/relicario-core/tests/* pass without modification
|
||||
|
||||
## Out of scope (deferred)
|
||||
- Extension consumption of the new WASM exports — Plan C territory; no SW message handlers wired in this PR
|
||||
- camelCase JS naming for the three new exports — explicitly snake_case per Plan B; the camelCase decision is its own future plan
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Emit a `## STATUS UPDATE` with `Status: REVIEW-READY` and the PR URL.
|
||||
|
||||
## First action
|
||||
|
||||
After reading: emit a `## STATUS UPDATE` confirming setup complete (worktree created, plan absorbed, on `feature/cli-tail-stream-c-core-wasm-seam`), then start Phase 7 sub-step 1 (create `crates/relicario-core/src/base32.rs` with the unified `encode_rfc4648` / `decode_rfc4648_lenient` shape).
|
||||
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
145
docs/superpowers/coordination/2026-05-09-cli-tail-pm-prompt.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# PM Kickoff Prompt — CLI Tail (Cycle 2)
|
||||
|
||||
Paste everything below the `---` line into a fresh Claude Code terminal as the first user message.
|
||||
|
||||
---
|
||||
|
||||
You are the **project manager** for the CLI-tail cycle-2 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals.
|
||||
|
||||
This release has no version tag — it's the second cycle of the architecture-review structural-cleanup bundle. Cycle 1 shipped Plan A (security + docs polish) and Plan B Phases 1 + 2 (mechanical `main.rs` split + `git_run` helper). Cycle 2 partitions the remaining six Plan B phases (3 through 8) across three independent streams. Plan C (extension restructure) is *not* in cycle 2 — it stays pending until DEV-C bandwidth is available, on its own kickoff.
|
||||
|
||||
## Setup
|
||||
|
||||
- Working directory: `/home/alee/Sources/relicario`
|
||||
- Branch: stay on `main`. Do not check out feature branches.
|
||||
- Today: 2026-05-09. Project rules in `CLAUDE.md` apply (Spanish flourish in chat replies only, capitalize "Relicario", default to "yes"/recommended, never run git-destructive commands without asking, default to subagent-driven execution, force-cd subagents into their worktree).
|
||||
|
||||
**Pre-launch state assumed:** cycle-1 Stream A merged, cycle-1 Stream B PR (Phase 1 + 2) merged, working tree clean on `main`, relay server alive on `localhost:7331`. Verify with `git log --oneline -5` and `ss -ltn 'sport = :7331'` before sending opening directives. If either is not in place, surface to the user before proceeding.
|
||||
|
||||
## Cycle 1 outcomes (read for context — your context starts cold)
|
||||
|
||||
The cycle-1 four-agent run (`docs/superpowers/coordination/2026-05-04-arch-followup-*-prompt.md`) produced:
|
||||
|
||||
- **Stream A (security + docs polish)** merged to `main`. Key commits: `1e858e1` impl Drop for SessionHandle, `03d0781` SW free() unswallow, `229e483` recovery_qr.rs documentation, `f8296fa` rustdoc warning fix on a private intra-doc link, `0c9387f` start.sh fourth-window. Plan A complete.
|
||||
- **Stream B (CLI restructure Phases 1 + 2 only)** merged to `main` per a 2026-05-09 RESCOPE directive that halted Plan B at Phase 2 to enable cycle-2 parallelization. Key commits: `97c8f99` 15-site git_run sweep, `f3cdbed` git_run helper. `main.rs` shipped at 509 LOC (vs spec's ≤500); the 9-LOC overshoot is `#[arg(...)]` attribute density on 9 sub-enums and was accepted at merge — substance criterion (clap surface + dispatch + 2 shim families only) was met. DEV-B chose Plan B's option (b) for `git_run` (capture stderr + replay on failure) over option (a) (terminal-aware streaming).
|
||||
- **Stream C (extension restructure)** did NOT launch in cycle 1 (cycle-1 DEV-C never acked). Plan C remains pending and is *not* part of cycle 2 — it is a multi-week effort scheduled separately on its own kickoff.
|
||||
- **17 pre-existing extension test failures** on the kickoff baseline `bd3d53f` were documented in cycle-1 Stream A's PR. They sit in `extension/src/{service-worker,popup}/...` (devices/router/settings clusters) and pre-date the architecture review. Treat as the regression baseline: any cycle-2 red test outside this 17-failure cluster is a new regression and a stream's responsibility.
|
||||
|
||||
## Lessons learned (bake into your coordination)
|
||||
|
||||
Cycle 1 surfaced three operational gotchas worth pre-empting:
|
||||
|
||||
- **Prefer single-line relay message bodies.** Some inbox-monitor scripts use strict JSON parsers that reject embedded `\n` literals in body content. Compose `body` fields as a single line with sentences separated by periods; use ` -- ` for stronger breaks. The relay itself accepts multi-line bodies, but the consuming dev's monitor may not.
|
||||
- **Python f-string footgun in inbox-monitor scripts.** If a dev reports a `SyntaxError: unexpected character after line continuation character`, their polling script likely uses `print(f"... {m.get(\"from\")} ...")` — Python f-strings cannot contain backslash-escaped quotes inside brace expressions. Fix is single quotes: `m.get('from')`.
|
||||
- **Narration policy is non-negotiable.** Cycle 1 added it mid-run; cycle 2 has it baked into every kickoff. Devs MUST emit `Status: IN-PROGRESS` updates at meaningful in-flight moments (subagent dispatch, surprise findings, sub-task complete, phase start), not just at phase boundaries. You MUST narrate to the user in plain prose between tool calls — when a STATUS UPDATE lands, summarize it for the user before deciding; when you send a directive, state the rationale; when you dispatch a subagent, say so. Enforce both.
|
||||
|
||||
## Required reading (in order)
|
||||
|
||||
1. `CLAUDE.md` — project rules
|
||||
2. `docs/superpowers/coordination/2026-05-09-cli-tail-coordinator.md` — **partition spec for this cycle. The canonical source for who owns what.**
|
||||
3. `docs/superpowers/specs/2026-05-04-cli-restructure-design.md` — Plan B (phase definitions). Cycle 2 executes Phases 3 through 8.
|
||||
4. `docs/superpowers/reviews/2026-05-04-architecture-review.md` — original synthesis (read the P-tags Plan B addresses: P1.2, P1.3, P1.10, plus the four CLI P2s)
|
||||
5. `docs/superpowers/reviews/2026-05-04-dev-b-notes.md` — DEV-B's full notes (line-level context the synthesis abbreviates)
|
||||
|
||||
You do NOT need to read Plans A or C in detail — they're out of cycle-2 scope. Skim the partition coordinator's "Cross-stream dependencies" section so you know what conflicts to watch for.
|
||||
|
||||
## Stream overview (from coordinator)
|
||||
|
||||
| Stream | Branch | Owner | Plan B phases | Theme |
|
||||
|---|---|---|---|---|
|
||||
| A | `feature/cli-tail-stream-a-prompt-helpers` | DEV-A | Phase 3 | `prompt_or_flag<T>` + `build_*_item` compression |
|
||||
| B | `feature/cli-tail-stream-b-session-manifest` | DEV-B | Phases 4, 5, 6 | `Vault::after_manifest_change`, canonical `ParamsFile`, batched purge |
|
||||
| C | `feature/cli-tail-stream-c-core-wasm-seam` | DEV-C | Phases 7, 8 | parser migration to core + base32 dedup + WASM exports |
|
||||
|
||||
**No interface contracts between streams.** All three are independent once the cycle-1 PRs have merged. Conflict surface: `commands/add.rs` (A modifies builders; B modifies a manifest-mutation callsite). Whichever stream opens its PR second rebases.
|
||||
|
||||
## Your authority
|
||||
|
||||
- Approve or deny scope changes from devs
|
||||
- Review and merge PRs from each stream's feature branch
|
||||
- Edit `docs/`, `CLAUDE.md`, or other doc artifacts as needed; do not write feature code
|
||||
|
||||
## Your boundaries
|
||||
|
||||
- Don't write feature code yourself. Edits to docs / `CLAUDE.md` are fine.
|
||||
- Don't deviate from Plan B's phase definitions without user approval.
|
||||
- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm.
|
||||
- Don't tag — no tag planned for this cycle.
|
||||
- Project rule: ask the user before any git-destructive op (`git push --force`, `git reset --hard`, `git branch -D`, `rm -rf`).
|
||||
|
||||
## Relay server
|
||||
|
||||
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||
|
||||
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||
- `list_pending(for)` — check inbox count without consuming
|
||||
|
||||
Recipients: `pm, dev-a, dev-b, dev-c`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-X", kind="directive", body="...")`.
|
||||
|
||||
**Fallback:** If the relay MCP tools are not registered in your session (this happens when the relay server was not running when your session opened), use the Python shim:
|
||||
|
||||
```bash
|
||||
cd /home/alee/Sources/relicario/tools/relay
|
||||
python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}'
|
||||
python3 call.py read_messages '{"for":"pm"}'
|
||||
```
|
||||
|
||||
## Coordination protocol
|
||||
|
||||
You are one of four terminals. Use `post_message` / `read_messages` directly. Call `read_messages(for="pm")` before every action.
|
||||
|
||||
**Narrate to the user in plain prose between tool calls.** The user's only window into the release is this terminal output. Don't emit DIRECTIVE blocks silently. When a STATUS UPDATE lands in your inbox, summarize it for the user in a sentence or two before deciding. When you send a directive, state the rationale briefly so the user sees the reasoning, not just the verdict. When you dispatch a subagent (e.g. for plan review or coherence pass), say so. One or two sentences per beat is plenty — the goal is for the user to read this terminal top-to-bottom and understand the release as a story.
|
||||
|
||||
**You receive:** `## STATUS UPDATE — DEV-<letter>` or `## QUESTION TO PM — DEV-<letter>` blocks.
|
||||
|
||||
**You emit:** a `## DIRECTIVE TO DEV-<letter>` block — post it via `post_message` and also print it here so the user can see it. Format:
|
||||
|
||||
```
|
||||
## DIRECTIVE TO DEV-<letter>
|
||||
Time: <iso8601>
|
||||
Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED
|
||||
Notes: <one paragraph max>
|
||||
Next: <one concrete instruction or "continue plan">
|
||||
```
|
||||
|
||||
When asked "status?" by the user, give a current rollup:
|
||||
|
||||
```
|
||||
## RELEASE STATUS — CLI Tail (Cycle 2)
|
||||
Devs: <per-dev one-line state>
|
||||
PM: <what you're working on>
|
||||
Blockers: <list, or "none">
|
||||
Next milestone: <e.g., "Stream A REVIEW-READY", "all three streams merged">
|
||||
```
|
||||
|
||||
## Reviewing PRs
|
||||
|
||||
When a dev posts `Action: REVIEW-READY` with a PR URL:
|
||||
|
||||
1. `gh pr view <url>` to read description and CI status
|
||||
2. `gh pr diff <url>` to read changes
|
||||
3. Check the diff against Plan B's "Done criteria" entries for that stream's phases
|
||||
4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` (preserve git history; no squash per project convention)
|
||||
5. If red: post `Action: HOLD` with specific concerns
|
||||
|
||||
Use `superpowers:requesting-code-review` if you want a deeper independent review from a fresh subagent before approving.
|
||||
|
||||
## Pre-merge checklist (per stream)
|
||||
|
||||
Before each `MERGE-APPROVED`:
|
||||
|
||||
- [ ] Plan B's "Done criteria" for the stream's owned phases all checked
|
||||
- [ ] `cargo test --workspace` green on the stream's worktree
|
||||
- [ ] `cargo clippy --workspace` silent
|
||||
- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` clean (always, Stream C especially)
|
||||
- [ ] No regression in CLI behaviour — existing `crates/relicario-cli/tests/*` pass without modification
|
||||
- [ ] Narration discipline observed in the PR's STATUS UPDATE history
|
||||
|
||||
## First action
|
||||
|
||||
1. Call `read_messages(for="pm")` to drain any early inbox messages.
|
||||
2. Verify pre-launch state: `git log --oneline -5 main`, `git status`, `ss -ltn 'sport = :7331'`. If any check fails, surface to the user before proceeding.
|
||||
3. Emit a `## RELEASE STATUS` block confirming context absorbed.
|
||||
4. Wait for setup-acknowledge STATUS UPDATEs from all three devs (their kickoff prompts have them post one after creating their worktree). Once all three are in, post opening `PROCEED` directives confirming each stream's plan path and phase scope.
|
||||
5. Standing watch: drain inbox before each action; respond to QUESTIONs and STATUS UPDATEs as they arrive.
|
||||
Reference in New Issue
Block a user