use dialoguer::{Input, Select};
use crate::auth;
use crate::config::{
CommunityConfig, PERSONAL_KEYRING_KEY, PersonalVtaConfig, community_keyring_key, load_config,
save_config,
};
use vta_sdk::prelude::*;
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) -> Option<String> {
vta_sdk::session::resolve_vta_url(did).await.ok()
}
async fn prompt_vta_url(
label: &str,
) -> Result<(Option<String>, String), Box<dyn std::error::Error>> {
let did: String = Input::new()
.with_prompt(format!("{label} VTA DID (press Enter to skip)"))
.allow_empty(true)
.interact_text()?;
let (did, discovered_url) = if did.is_empty() {
(None, None)
} else {
eprintln!("Resolving DID...");
let url = match resolve_vta_url(&did).await {
Some(url) => {
eprintln!(" Discovered VTA URL: {url}");
Some(url)
}
None => {
eprintln!(" No #vta-rest service endpoint found in DID document.");
None
}
};
(Some(did), url)
};
let vta_url: String = if let Some(url) = discovered_url {
Input::new()
.with_prompt(format!("{label} VTA URL"))
.default(url)
.interact_text()?
} else {
Input::new()
.with_prompt(format!("{label} VTA URL"))
.interact_text()?
};
Ok((did, vta_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_url("Personal").await?;
let personal_credential: String = Input::new()
.with_prompt("Personal VTA credential (base64)")
.interact_text()?;
eprintln!();
auth::login(&personal_credential, &personal_url, PERSONAL_KEYRING_KEY).await?;
config.personal_vta = Some(PersonalVtaConfig {
url: personal_url.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_url("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 mut community_vta_did_for_config: Option<String> = community_did.clone();
let context_id = match join_choice {
0 => {
let credential: String = Input::new()
.with_prompt("Community admin credential (base64)")
.interact_text()?;
let keyring_key = community_keyring_key(&community_slug);
eprintln!();
auth::login(&credential, &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!("Generating community admin credential...");
let cred_req = GenerateCredentialsRequest::new("admin")
.label(format!("CNM community admin — {community_slug}"))
.contexts(vec![context_slug.clone()]);
let resp = personal_client.generate_credentials(cred_req).await?;
let bundle = vta_sdk::credentials::CredentialBundle::decode(&resp.credential)
.map_err(|e| format!("failed to decode credential: {e:?}"))?;
let private_key = &bundle.private_key_multibase;
let community_vta_did = match &community_did {
Some(did) => did.clone(),
None => {
let did: String = Input::new()
.with_prompt("Community VTA DID (required for authentication)")
.interact_text()?;
community_vta_did_for_config = Some(did.clone());
did
}
};
let keyring_key = community_keyring_key(&community_slug);
auth::store_session_direct(
&keyring_key,
&resp.did,
private_key,
&community_vta_did,
&community_url,
)?;
eprintln!();
eprintln!(
"\x1b[1;32mGenerated community admin DID:\x1b[0m {}",
resp.did
);
eprintln!();
eprintln!("Share this DID with the community administrator.");
eprintln!("They will run:");
eprintln!(" vta import-did --did {}", resp.did);
eprintln!();
eprintln!("Once access is granted, cnm will authenticate automatically.");
eprintln!();
Some(context_slug)
}
};
config.communities.insert(
community_slug.clone(),
CommunityConfig {
name: community_name,
url: community_url,
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_url("Community").await?;
let credential: String = Input::new()
.with_prompt("Community admin credential (base64)")
.interact_text()?;
let keyring_key = community_keyring_key(&community_slug);
eprintln!();
auth::login(&credential, &community_url, &keyring_key).await?;
config.communities.insert(
community_slug.clone(),
CommunityConfig {
name: community_name,
url: community_url,
context_id: None,
vta_did: None,
},
);
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 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 cred_req = GenerateCredentialsRequest {
role: "admin".into(),
label: Some(format!("CNM community admin — {slug} (bootstrapped)")),
allowed_contexts: vec![context_id.to_string()],
};
let resp = personal_client.generate_credentials(cred_req).await?;
let bundle = vta_sdk::credentials::CredentialBundle::decode(&resp.credential)
.map_err(|e| format!("failed to decode credential: {e:?}"))?;
let private_key = &bundle.private_key_multibase;
let keyring_key = community_keyring_key(slug);
auth::store_session_direct(
&keyring_key,
&resp.did,
private_key,
community_vta_did,
&community.url,
)?;
eprintln!();
eprintln!(
"\x1b[1;32mBootstrapped community session with new DID:\x1b[0m {}",
resp.did
);
eprintln!();
eprintln!("This is a NEW DID. You must grant it access on the community VTA:");
eprintln!(" vta import-did --did {}", resp.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");
}
}