use vta_sdk::client::VtaClient;
use vta_cli_common::commands::contexts;
use vta_cli_common::render::{DIM, GREEN, RED, RESET, YELLOW};
use crate::auth;
use crate::cli::VtaCommands;
use crate::config::{self, PnmConfig};
pub(crate) async fn run_offline(
pnm_config: &mut PnmConfig,
vta_override: Option<&str>,
command: &VtaCommands,
) -> bool {
match command {
VtaCommands::List => {
if pnm_config.vtas.is_empty() {
println!("No VTAs configured.");
println!("\nRun `pnm setup` to configure your first VTA.");
} else {
let default = pnm_config.default_vta.as_deref().unwrap_or("");
for (slug, vta) in &pnm_config.vtas {
let marker = if slug == default { " (default)" } else { "" };
println!(" {slug}{marker}");
println!(" Name: {}", vta.name);
if let Some(ref did) = vta.vta_did {
println!(" DID: {did}");
}
println!();
}
}
true
}
VtaCommands::Use { slug } => {
if !pnm_config.vtas.contains_key(slug) {
eprintln!(
"Error: VTA '{slug}' not found.\n\nConfigured VTAs: {}",
pnm_config
.vtas
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
);
std::process::exit(1);
}
pnm_config.default_vta = Some(slug.clone());
if let Err(e) = config::save_config(pnm_config) {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
println!("Default VTA set to '{slug}'.");
true
}
VtaCommands::Delete { slug, yes } => {
let Some(vta) = pnm_config.vtas.get(slug) else {
eprintln!("Error: VTA '{slug}' not found.");
std::process::exit(1);
};
let key = config::vta_keyring_key(slug);
let client_did = auth::loaded_session(&key).map(|s| s.client_did);
if !yes {
println!("About to delete VTA connection '{slug}':");
println!(" Name: {}", vta.name);
if let Some(ref did) = vta.vta_did {
println!(" DID: {did}");
}
println!(" The stored credential for this VTA will be deleted.");
if pnm_config.default_vta.as_deref() == Some(slug.as_str()) {
println!(" This is the default VTA — the default will move.");
}
println!();
print_local_only_notice(slug, client_did.as_deref());
println!();
let proceed = contexts::confirm_destructive("Proceed with deletion?")
.unwrap_or_else(|e| {
eprintln!("Error reading confirmation: {e}");
std::process::exit(1);
});
if !proceed {
println!("Aborted.");
return true;
}
}
pnm_config.vtas.remove(slug);
if pnm_config.default_vta.as_deref() == Some(slug.as_str()) {
pnm_config.default_vta = pnm_config.vtas.keys().next().cloned();
}
auth::logout(&key);
if let Err(e) = config::save_config(pnm_config) {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
println!("{GREEN}✓{RESET} VTA connection '{slug}' deleted.");
if *yes {
print_local_only_notice(slug, client_did.as_deref());
}
true
}
VtaCommands::Info => {
match config::resolve_vta(vta_override, pnm_config) {
Ok((slug, vta)) => {
println!("Active VTA: {slug}");
println!(" Name: {}", vta.name);
if let Some(ref did) = vta.vta_did {
println!(" DID: {did}");
if let Ok(url) = vta_sdk::session::resolve_vta_url(did).await {
println!(" URL: {url} (from DID)");
}
}
let key = config::vta_keyring_key(&slug);
auth::status(&key);
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
true
}
VtaCommands::Qr { did, out } => {
let (label, did) = match qr_subject(vta_override, pnm_config, did.as_deref()) {
Ok(subject) => subject,
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
};
if let Err(e) = show_qr(label.as_deref(), &did, out.as_deref()) {
eprintln!("Error: {e}");
std::process::exit(1);
}
true
}
VtaCommands::Restart => false,
}
}
fn qr_subject(
vta_override: Option<&str>,
pnm_config: &PnmConfig,
explicit: Option<&str>,
) -> Result<(Option<String>, String), Box<dyn std::error::Error>> {
if let Some(did) = explicit {
if !did.starts_with("did:") {
return Err(format!("'{did}' is not a DID — it should start with `did:`").into());
}
return Ok((None, did.to_string()));
}
let (slug, vta) = config::resolve_vta(vta_override, pnm_config)?;
let did = vta.vta_did.clone().ok_or_else(|| {
format!(
"no DID is stored for VTA '{slug}'.\n\n\
Finish its setup with `pnm setup continue {slug}`, or pass --did <DID>."
)
})?;
Ok((Some(format!("{slug} ({})", vta.name)), did))
}
fn show_qr(
label: Option<&str>,
did: &str,
out: Option<&std::path::Path>,
) -> Result<(), Box<dyn std::error::Error>> {
let lines = vta_cli_common::qr::terminal_lines(did)?;
if let Some(label) = label {
println!("{DIM}VTA{RESET} {label}");
}
println!();
for line in &lines {
println!(" {line}");
}
println!();
println!("{DIM}DID{RESET} {did}");
println!("{DIM}Scan with Keyring. The code holds only this public DID.{RESET}");
if let Some(path) = out {
std::fs::write(path, vta_cli_common::qr::svg(did)?)
.map_err(|e| format!("could not write {}: {e}", path.display()))?;
println!("{GREEN}✓{RESET} Wrote {}", path.display());
}
Ok(())
}
pub(crate) async fn run_restart(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
println!("Requesting VTA restart...");
client.restart().await?;
println!("{GREEN}✓{RESET} Restart initiated");
println!("Waiting for VTA to come back...");
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for attempt in 1..=5 {
match client.health().await {
Ok(resp) => {
let ver = resp.version.as_deref().unwrap_or("?");
println!("{GREEN}✓{RESET} VTA is back (v{ver})");
return Ok(());
}
Err(_) if attempt < 5 => {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
Err(e) => {
println!("{RED}✗{RESET} VTA did not come back after restart: {e}");
println!(" The VTA may still be restarting. Try `pnm health` in a few seconds.");
}
}
}
Ok(())
}
fn print_local_only_notice(slug: &str, client_did: Option<&str>) {
eprintln!(
"{YELLOW}⚠{RESET} This deletes only the local connection and credential for '{slug}'. \
The VTA, and its ACL entry for this credential, are untouched."
);
match client_did {
Some(did) => eprintln!(
" To revoke the credential on the VTA as well, run this from an admin \
connection to it{DIM} (not if it is that VTA's only admin){RESET}:\n \
pnm acl delete {did}"
),
None => eprintln!(
" To revoke a credential on the VTA as well, run `pnm acl delete <did>` from \
an admin connection to it."
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::VtaConfig;
fn config_with(vta_did: Option<&str>) -> PnmConfig {
let mut config = PnmConfig::default();
config.default_vta = Some("personal".into());
config.vtas.insert(
"personal".into(),
VtaConfig {
name: "Personal VTA".into(),
vta_did: vta_did.map(str::to_string),
url: None,
mediator_did: None,
},
);
config
}
#[test]
fn qr_draws_the_default_vta_did() {
let config = config_with(Some("did:webvh:QmScid:vta.example.com"));
let (label, did) = qr_subject(None, &config, None).unwrap();
assert_eq!(did, "did:webvh:QmScid:vta.example.com");
assert_eq!(label.as_deref(), Some("personal (Personal VTA)"));
}
#[test]
fn qr_did_flag_overrides_the_vta_and_needs_no_config() {
let (label, did) = qr_subject(None, &PnmConfig::default(), Some("did:key:z6Mk")).unwrap();
assert_eq!(did, "did:key:z6Mk");
assert!(label.is_none());
}
#[test]
fn qr_refuses_something_that_is_not_a_did() {
let err = qr_subject(None, &PnmConfig::default(), Some("https://vta.example.com"))
.unwrap_err()
.to_string();
assert!(err.contains("not a DID"), "{err}");
}
#[test]
fn qr_on_an_unknown_vta_names_the_list_command() {
let config = config_with(Some("did:webvh:QmScid:vta.example.com"));
let err = qr_subject(Some("missing"), &config, None)
.unwrap_err()
.to_string();
assert!(err.contains("pnm vta list"), "{err}");
}
}