use std::path::Path;
use dialoguer::{Input, Select};
use vta_sdk::credentials::CredentialBundle;
use crate::auth;
use crate::config::{
CommunityConfig, PERSONAL_KEYRING_KEY, PersonalVtaConfig, community_keyring_key, config_dir,
load_config, save_config,
};
use vta_sdk::prelude::*;
async fn prompt_for_sealed_credential(
label: &str,
) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
eprintln!();
eprintln!("Before continuing, generate a bootstrap request for the {label} admin:");
eprintln!(" cnm bootstrap request --out request.json");
eprintln!("Hand that file to the admin, then return here with the armored sealed");
eprintln!("bundle they produce (and its SHA-256 digest for verification).");
eprintln!();
let path: String = Input::new()
.with_prompt(format!("Path to the {label} armored sealed bundle"))
.interact_text()?;
let digest: String = Input::new()
.with_prompt(format!(
"Expected SHA-256 digest for {label} bundle (empty = skip verification)"
))
.allow_empty(true)
.interact_text()?;
let (expect_digest, no_verify) = if digest.trim().is_empty() {
(None, true)
} else {
(Some(digest.trim().to_string()), false)
};
open_sealed_credential(Path::new(path.trim()), expect_digest.as_deref(), no_verify)
}
fn open_sealed_credential(
bundle_path: &Path,
expect_digest: Option<&str>,
no_verify_digest: bool,
) -> Result<CredentialBundle, Box<dyn std::error::Error>> {
let config_dir = config_dir()?;
if no_verify_digest {
vta_cli_common::sealed_consumer::warn_no_verify_digest();
}
let opened = vta_cli_common::sealed_consumer::open_armored_bundle(
bundle_path,
&config_dir,
expect_digest,
no_verify_digest,
)?;
eprintln!(
"Sealed bundle opened ({} — digest {}).",
opened.bundle_id_hex, opened.digest
);
vta_cli_common::sealed_consumer::extract_admin_credential(opened.payload)
}
fn slugify(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
slug.trim_matches('-')
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
async fn resolve_vta_url(did: &str) -> Result<String, Box<dyn std::error::Error>> {
vta_sdk::session::resolve_vta_url(did)
.await
.map_err(|e| format!("could not resolve REST endpoint from {did}: {e}").into())
}
async fn prompt_vta_did(label: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
let did: String = Input::new()
.with_prompt(format!("{label} VTA DID"))
.interact_text()?;
let did = did.trim().to_string();
if did.is_empty() {
return Err(format!("{label} VTA DID is required").into());
}
eprintln!("Resolving DID...");
let url = resolve_vta_url(&did).await?;
eprintln!(" REST endpoint: {url}");
Ok((did, url))
}
pub async fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("Welcome to the CNM setup wizard.\n");
let mut config = load_config()?;
let (personal_did, personal_url) = prompt_vta_did("Personal").await?;
let personal_bundle = prompt_for_sealed_credential("personal VTA").await?;
eprintln!();
auth::login(&personal_bundle, &personal_url, PERSONAL_KEYRING_KEY).await?;
config.personal_vta = Some(PersonalVtaConfig {
vta_did: Some(personal_did.clone()),
});
let community_name: String = Input::new().with_prompt("Community name").interact_text()?;
let default_slug = slugify(&community_name);
let community_slug: String = Input::new()
.with_prompt("Community slug (short identifier)")
.default(default_slug)
.interact_text()?;
let (community_did, community_url) = prompt_vta_did("Community").await?;
let join_options = &["Import existing credential", "Generate from personal VTA"];
let join_choice = Select::new()
.with_prompt("How do you want to join this community?")
.items(join_options)
.default(0)
.interact()?;
let community_vta_did_for_config: Option<String> = Some(community_did.clone());
let context_id = match join_choice {
0 => {
let bundle = prompt_for_sealed_credential("community VTA").await?;
let keyring_key = community_keyring_key(&community_slug);
eprintln!();
auth::login(&bundle, &community_url, &keyring_key).await?;
None
}
_ => {
let context_slug = format!("cnm-{community_slug}");
let context_name = format!("CNM - {community_name}");
let personal_client = VtaClient::new(&personal_url);
let token = auth::ensure_authenticated(&personal_url, PERSONAL_KEYRING_KEY).await?;
personal_client.set_token(token);
eprintln!("\nCreating context '{context_name}' in personal VTA...");
let ctx_req = CreateContextRequest::new(&context_slug, &context_name)
.description(format!("Community admin identity for {}", community_name));
match personal_client.create_context(ctx_req).await {
Ok(ctx) => {
eprintln!(" Context created: {} ({})", ctx.id, ctx.base_path);
}
Err(ref e) if matches!(e, vta_sdk::error::VtaError::Conflict(_)) => {
eprintln!(" Context '{context_slug}' already exists, reusing it.");
}
Err(e) => {
return Err(e.into());
}
}
eprintln!("Minting community admin credential locally...");
let (bundle, admin_did) = vta_cli_common::local_keygen::generate_admin_did_key(
community_did.clone(),
Some(community_url.clone()),
);
let acl_req = vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
.label(format!("CNM community admin — {community_slug}"))
.contexts(vec![context_slug.clone()]);
personal_client.create_acl(acl_req).await?;
let keyring_key = community_keyring_key(&community_slug);
auth::store_session_direct(
&keyring_key,
&admin_did,
&bundle.private_key_multibase,
&community_did,
)?;
eprintln!();
eprintln!("\x1b[1;32mGenerated community admin DID:\x1b[0m {admin_did}");
eprintln!();
eprintln!("Share this DID with the community administrator.");
eprintln!("They will run:");
eprintln!(" vta import-did --did {admin_did}");
eprintln!();
eprintln!("Once access is granted, cnm will authenticate automatically.");
eprintln!();
Some(context_slug)
}
};
let _ = community_url;
config.communities.insert(
community_slug.clone(),
CommunityConfig {
name: community_name,
context_id,
vta_did: community_vta_did_for_config,
},
);
if config.default_community.is_none() || config.communities.len() == 1 {
config.default_community = Some(community_slug.clone());
}
save_config(&config)?;
eprintln!();
eprintln!("\x1b[1;32mSetup complete!\x1b[0m");
let path = crate::config::config_path()?;
eprintln!(" Config saved to: {}", path.display());
eprintln!(" Default community: {community_slug}");
eprintln!();
Ok(())
}
pub async fn add_community() -> Result<(), Box<dyn std::error::Error>> {
let mut config = load_config()?;
let community_name: String = Input::new().with_prompt("Community name").interact_text()?;
let default_slug = slugify(&community_name);
let community_slug: String = Input::new()
.with_prompt("Community slug (short identifier)")
.default(default_slug)
.interact_text()?;
if config.communities.contains_key(&community_slug) {
return Err(
format!("community '{community_slug}' already exists. Use a different slug.").into(),
);
}
let (community_did, community_url) = prompt_vta_did("Community").await?;
let bundle = prompt_for_sealed_credential("community VTA").await?;
let keyring_key = community_keyring_key(&community_slug);
eprintln!();
auth::login(&bundle, &community_url, &keyring_key).await?;
config.communities.insert(
community_slug.clone(),
CommunityConfig {
name: community_name,
context_id: None,
vta_did: Some(community_did),
},
);
if config.default_community.is_none() {
config.default_community = Some(community_slug.clone());
}
save_config(&config)?;
eprintln!();
eprintln!("Community '{community_slug}' added.");
Ok(())
}
pub async fn bootstrap_community_session(
slug: &str,
community: &CommunityConfig,
personal_url: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let context_id = community
.context_id
.as_deref()
.ok_or("community has no context_id")?;
let community_vta_did = community
.vta_did
.as_deref()
.ok_or("community has no vta_did in config (setup ran before this feature was added)")?;
let community_url = resolve_vta_url(community_vta_did).await?;
let token = auth::ensure_authenticated(personal_url, PERSONAL_KEYRING_KEY).await?;
let personal_client = VtaClient::new(personal_url);
personal_client.set_token(token);
eprintln!("Bootstrapping community session from personal VTA...");
let (bundle, admin_did) = vta_cli_common::local_keygen::generate_admin_did_key(
community_vta_did.to_string(),
Some(community_url),
);
let acl_req = vta_sdk::client::CreateAclRequest::new(&admin_did, "admin")
.label(format!("CNM community admin — {slug} (bootstrapped)"))
.contexts(vec![context_id.to_string()]);
personal_client.create_acl(acl_req).await?;
let keyring_key = community_keyring_key(slug);
auth::store_session_direct(
&keyring_key,
&admin_did,
&bundle.private_key_multibase,
community_vta_did,
)?;
eprintln!();
eprintln!("\x1b[1;32mBootstrapped community session with new DID:\x1b[0m {admin_did}");
eprintln!();
eprintln!("This is a NEW DID. You must grant it access on the community VTA:");
eprintln!(" vta import-did --did {admin_did}");
eprintln!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify_basic() {
assert_eq!(slugify("Storm Network"), "storm-network");
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(slugify("Acme Corp."), "acme-corp");
}
#[test]
fn test_slugify_multiple_spaces() {
assert_eq!(slugify(" My Test Community "), "my-test-community");
}
#[test]
fn test_slugify_already_slug() {
assert_eq!(slugify("already-good"), "already-good");
}
#[test]
fn test_slugify_uppercase() {
assert_eq!(slugify("UPPERCASE"), "uppercase");
}
#[test]
fn test_slugify_numbers() {
assert_eq!(slugify("Community 42"), "community-42");
}
}