use anyhow::{Context, Result, bail};
use console::{Term, style};
use dialoguer::{Confirm, Input, MultiSelect, Password, Select, theme::ColorfulTheme};
use indicatif::{ProgressBar, ProgressStyle};
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use crate::pages::bundle::{BundleBuilder, BundleConfig};
use crate::pages::confirmation::{
ConfirmationConfig, ConfirmationFlow, ConfirmationStep, PasswordStrengthAction, StepValidation,
UNENCRYPTED_ACK_PHRASE, unencrypted_warning_lines, validate_unencrypted_ack,
};
use crate::pages::deploy_cloudflare::{CloudflareConfig, CloudflareDeployer};
use crate::pages::deploy_github::GitHubDeployer;
use crate::pages::docs::{DocConfig, DocumentationGenerator};
use crate::pages::encrypt::EncryptionEngine;
use crate::pages::export::{ExportEngine, ExportFilter, PathMode};
use crate::pages::password::{PasswordStrength, format_strength_inline, validate_password};
use crate::pages::secret_scan::{
SecretScanConfig, SecretScanFilters, print_human_report, wizard_secret_scan,
};
use crate::pages::size::{BundleVerifier, SizeEstimate, SizeLimitResult};
use crate::pages::summary::{
ExclusionSet, PrePublishSummary, SummaryFilters, SummaryGenerator, format_size,
};
use crate::storage::sqlite::FrankenStorage;
use frankensqlite::Connection;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeployTarget {
Local,
GitHubPages,
CloudflarePages,
}
impl std::fmt::Display for DeployTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeployTarget::Local => write!(f, "Local export only"),
DeployTarget::GitHubPages => write!(f, "GitHub Pages"),
DeployTarget::CloudflarePages => write!(f, "Cloudflare Pages"),
}
}
}
#[derive(Clone)]
pub struct WizardState {
pub agents: Vec<String>,
pub time_range: Option<String>,
pub workspaces: Option<Vec<PathBuf>>,
pub password: Option<String>,
pub recovery_secret: Option<Vec<u8>>,
pub generate_recovery: bool,
pub generate_qr: bool,
pub title: String,
pub description: String,
pub hide_metadata: bool,
pub target: DeployTarget,
pub output_dir: PathBuf,
pub repo_name: Option<String>,
pub db_path: PathBuf,
pub exclusions: ExclusionSet,
pub last_summary: Option<PrePublishSummary>,
pub secret_scan_has_findings: bool,
pub secret_scan_has_critical: bool,
pub secret_scan_count: usize,
pub password_entropy_bits: f64,
pub no_encryption: bool,
pub unencrypted_confirmed: bool,
pub cloudflare_branch: Option<String>,
pub cloudflare_account_id: Option<String>,
pub cloudflare_api_token: Option<String>,
pub final_site_dir: Option<PathBuf>,
}
impl std::fmt::Debug for WizardState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WizardState")
.field("agents", &self.agents)
.field("time_range", &self.time_range)
.field("workspaces", &self.workspaces)
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.field(
"recovery_secret",
&self.recovery_secret.as_ref().map(|_| "[REDACTED]"),
)
.field("generate_recovery", &self.generate_recovery)
.field("generate_qr", &self.generate_qr)
.field("title", &self.title)
.field("description", &self.description)
.field("hide_metadata", &self.hide_metadata)
.field("target", &self.target)
.field("output_dir", &self.output_dir)
.field("repo_name", &self.repo_name)
.field("db_path", &self.db_path)
.field("exclusions", &self.exclusions)
.field("last_summary", &self.last_summary)
.field("secret_scan_has_findings", &self.secret_scan_has_findings)
.field("secret_scan_has_critical", &self.secret_scan_has_critical)
.field("secret_scan_count", &self.secret_scan_count)
.field("password_entropy_bits", &self.password_entropy_bits)
.field("no_encryption", &self.no_encryption)
.field("unencrypted_confirmed", &self.unencrypted_confirmed)
.field("cloudflare_branch", &self.cloudflare_branch)
.field("cloudflare_account_id", &self.cloudflare_account_id)
.field(
"cloudflare_api_token",
&self.cloudflare_api_token.as_ref().map(|_| "[REDACTED]"),
)
.field("final_site_dir", &self.final_site_dir)
.finish()
}
}
impl Default for WizardState {
fn default() -> Self {
let db_path = crate::default_db_path();
Self {
agents: Vec::new(),
time_range: None,
workspaces: None,
password: None,
recovery_secret: None,
generate_recovery: true,
generate_qr: false,
title: "cass Archive".to_string(),
description: "Encrypted archive of AI coding agent conversations".to_string(),
hide_metadata: false,
target: DeployTarget::Local,
output_dir: PathBuf::from("cass-export"),
repo_name: None,
db_path,
exclusions: ExclusionSet::new(),
last_summary: None,
secret_scan_has_findings: false,
secret_scan_has_critical: false,
secret_scan_count: 0,
password_entropy_bits: 0.0,
no_encryption: false,
unencrypted_confirmed: false,
cloudflare_branch: None,
cloudflare_account_id: None,
cloudflare_api_token: None,
final_site_dir: None,
}
}
}
fn truncate_sample_title(title: &str) -> String {
if title.len() > 30 {
format!("{}...", &title[..title.floor_char_boundary(27)])
} else {
title.to_string()
}
}
pub struct PagesWizard {
state: WizardState,
no_encryption_mode: bool,
}
impl Default for PagesWizard {
fn default() -> Self {
Self::new()
}
}
impl PagesWizard {
pub fn new() -> Self {
Self {
state: WizardState::default(),
no_encryption_mode: false,
}
}
pub fn set_db_path(&mut self, db_path: PathBuf) {
self.state.db_path = db_path;
}
pub fn set_no_encryption(&mut self, no_encryption: bool) {
self.no_encryption_mode = no_encryption;
self.state.no_encryption = no_encryption;
}
pub fn set_deploy_target(&mut self, target: DeployTarget) {
self.state.target = target;
}
pub fn set_repo_name(&mut self, name: String) {
self.state.repo_name = Some(name);
}
pub fn set_cloudflare_branch(&mut self, branch: String) {
self.state.cloudflare_branch = Some(branch);
}
pub fn set_cloudflare_account_id(&mut self, account_id: String) {
self.state.cloudflare_account_id = Some(account_id);
}
pub fn set_cloudflare_api_token(&mut self, api_token: String) {
self.state.cloudflare_api_token = Some(api_token);
}
pub fn run(&mut self) -> Result<()> {
let mut term = Term::stdout();
let theme = ColorfulTheme::default();
term.clear_screen()?;
self.print_header(&mut term)?;
if self.no_encryption_mode && !self.step_unencrypted_warning(&mut term, &theme)? {
writeln!(term, "{}", style("Export cancelled.").yellow())?;
return Ok(());
}
self.step_content_selection(&mut term, &theme)?;
self.step_secret_scan(&mut term, &theme)?;
if !self.no_encryption_mode {
self.step_security_config(&mut term, &theme)?;
} else {
self.state.generate_recovery = false;
self.state.generate_qr = false;
}
self.step_site_config(&mut term, &theme)?;
self.step_deployment_target(&mut term, &theme)?;
if !self.step_summary(&mut term, &theme)? {
writeln!(term, "{}", style("Export cancelled.").yellow())?;
return Ok(());
}
if !self.no_encryption_mode && !self.step_confirmation(&mut term, &theme)? {
writeln!(term, "{}", style("Export cancelled.").yellow())?;
return Ok(());
}
self.step_export(&mut term)?;
self.step_deploy(&mut term)?;
Ok(())
}
fn step_unencrypted_warning(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<bool> {
writeln!(term)?;
writeln!(term, "{}", style("⚠️ SECURITY WARNING").red().bold())?;
writeln!(term, "{}", style("━".repeat(60)).red())?;
writeln!(term)?;
for line in unencrypted_warning_lines() {
if line.is_empty() {
writeln!(term)?;
} else {
writeln!(term, " {}", line)?;
}
}
writeln!(term)?;
writeln!(term, "{}", style("━".repeat(60)).red())?;
writeln!(term)?;
writeln!(term, "To proceed with unencrypted export, type exactly:")?;
writeln!(term)?;
writeln!(term, " {}", style(UNENCRYPTED_ACK_PHRASE).cyan().bold())?;
writeln!(term)?;
loop {
let input: String = Input::with_theme(theme)
.with_prompt("Your input (or \"cancel\" to abort)")
.interact_text()?;
if input.trim().to_lowercase() == "cancel" {
return Ok(false);
}
match validate_unencrypted_ack(&input) {
StepValidation::Passed => {
writeln!(term)?;
let confirmed = Confirm::with_theme(theme)
.with_prompt(
"Are you ABSOLUTELY SURE you want to export WITHOUT encryption?",
)
.default(false)
.interact()?;
if !confirmed {
writeln!(term)?;
writeln!(
term,
" {}",
style("Good choice. Export cancelled.").green()
)?;
writeln!(
term,
" Remove --no-encryption to export with encryption (recommended)."
)?;
return Ok(false);
}
self.state.unencrypted_confirmed = true;
writeln!(term)?;
writeln!(
term,
" {} Unencrypted export acknowledged",
style("⚠").yellow()
)?;
writeln!(
term,
" {}",
style("Proceeding without encryption...").dim()
)?;
return Ok(true);
}
StepValidation::Failed(msg) => {
writeln!(term, " {} {}", style("✗").red(), msg)?;
}
}
}
}
fn print_header(&self, term: &mut Term) -> Result<()> {
writeln!(
term,
"{}",
style("🔐 cass Pages Export Wizard").bold().cyan()
)?;
writeln!(
term,
"Create an encrypted, searchable web archive of your AI coding agent conversations."
)?;
writeln!(term)?;
Ok(())
}
fn step_content_selection(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
writeln!(term, "\n{}", style("Step 1 of 9: Content Selection").bold())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
let storage = FrankenStorage::open_readonly(&self.state.db_path)
.context("Failed to open database. Run 'cass index' first.")?;
let db_agents = storage.list_agents()?;
let db_workspaces = storage.list_workspaces()?;
drop(storage);
if db_agents.is_empty() {
writeln!(
term,
"{}",
style("⚠ No agents found in database. Run 'cass index' first.").red()
)?;
bail!("No agents found in database");
}
let agent_items: Vec<String> = db_agents
.iter()
.map(|a| format!("{} ({})", a.name, a.slug))
.collect();
let selected_agents = MultiSelect::with_theme(theme)
.with_prompt("Which agents would you like to include?")
.items(&agent_items)
.defaults(&vec![true; agent_items.len()])
.interact()?;
self.state.agents = selected_agents
.iter()
.map(|&i| db_agents[i].slug.clone())
.collect();
if self.state.agents.is_empty() {
bail!("No agents selected. Export cancelled.");
}
writeln!(
term,
" {} {} agents selected",
style("✓").green(),
self.state.agents.len()
)?;
self.state.workspaces = None;
if !db_workspaces.is_empty() {
let include_all = Confirm::with_theme(theme)
.with_prompt("Include all workspaces?")
.default(true)
.interact()?;
if !include_all {
let workspace_items: Vec<String> = db_workspaces
.iter()
.map(|w| {
w.display_name
.clone()
.unwrap_or_else(|| w.path.to_string_lossy().to_string())
})
.collect();
let selected_ws = MultiSelect::with_theme(theme)
.with_prompt("Select workspaces to include:")
.items(&workspace_items)
.interact()?;
self.state.workspaces = Some(
selected_ws
.iter()
.map(|&i| db_workspaces[i].path.clone())
.collect(),
);
writeln!(
term,
" {} {} workspaces selected",
style("✓").green(),
selected_ws.len()
)?;
if selected_ws.is_empty() {
writeln!(
term,
" {} No workspaces selected. The export will contain no conversations.",
style("ℹ").yellow()
)?;
}
}
}
let time_options = vec![
"All time",
"Last 7 days",
"Last 30 days",
"Last 90 days",
"Last year",
];
let time_selection = Select::with_theme(theme)
.with_prompt("Time range")
.default(0)
.items(&time_options)
.interact()?;
self.state.time_range = match time_selection {
1 => Some("-7d".to_string()),
2 => Some("-30d".to_string()),
3 => Some("-90d".to_string()),
4 => Some("-365d".to_string()),
_ => None,
};
writeln!(
term,
" {} Time range: {}",
style("✓").green(),
time_options[time_selection]
)?;
Ok(())
}
fn step_secret_scan(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
writeln!(term, "\n{}", style("Step 2 of 9: Secret Scan").bold())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
let since_ts = self
.state
.time_range
.as_deref()
.and_then(crate::ui::time_parser::parse_time_input);
let filters = SecretScanFilters {
agents: if self.state.agents.is_empty() {
None
} else {
Some(self.state.agents.clone())
},
workspaces: self.state.workspaces.clone(),
since_ts,
until_ts: None,
};
let config = SecretScanConfig::from_inputs(&[], &[])?;
if !config.allowlist_raw.is_empty() || !config.denylist_raw.is_empty() {
writeln!(
term,
" {} Allowlist patterns: {} | Denylist patterns: {}",
style("ℹ").blue(),
config.allowlist_raw.len(),
config.denylist_raw.len()
)?;
}
let report = wizard_secret_scan(&self.state.db_path, &filters, &config)?;
print_human_report(term, &report, 3)?;
self.state.secret_scan_has_findings = report.summary.total > 0;
self.state.secret_scan_has_critical = report.summary.has_critical;
self.state.secret_scan_count = report.summary.total;
if report.summary.has_critical {
writeln!(
term,
" {} Critical secrets detected. Export is blocked without acknowledgement.",
style("✗").red()
)?;
let ack: String = Input::with_theme(theme)
.with_prompt("Type \"I UNDERSTAND\" to proceed")
.interact_text()?;
if ack.trim() != "I UNDERSTAND" {
bail!("Export cancelled due to critical secrets");
}
writeln!(term, " {} Acknowledged", style("✓").green())?;
}
Ok(())
}
fn step_security_config(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
writeln!(
term,
"\n{}",
style("Step 3 of 9: Security Configuration").bold()
)?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
let password = Password::with_theme(theme)
.with_prompt("Archive password (min 8 characters)")
.with_confirmation("Confirm password", "Passwords don't match")
.validate_with(|input: &String| -> Result<(), &str> {
if input.chars().count() >= 8 {
Ok(())
} else {
Err("Password must be at least 8 characters")
}
})
.interact()?;
self.state.password = Some(password.clone());
writeln!(term, " {} Password set", style("✓").green())?;
let validation = validate_password(&password);
self.state.password_entropy_bits = validation.entropy_bits;
writeln!(
term,
" Password strength: {}",
format_strength_inline(&validation)
)?;
writeln!(term, " Entropy: {:.0} bits", validation.entropy_bits)?;
if validation.strength != PasswordStrength::Strong && !validation.suggestions.is_empty() {
writeln!(term, " {}", style("Suggestions:").dim())?;
for suggestion in &validation.suggestions {
writeln!(
term,
" {} {}",
style("•").dim(),
style(suggestion).dim()
)?;
}
}
self.state.generate_recovery = Confirm::with_theme(theme)
.with_prompt("Generate recovery secret? (recommended)")
.default(true)
.interact()?;
if self.state.generate_recovery {
writeln!(
term,
" {} Recovery secret will be generated",
style("✓").green()
)?;
}
self.state.generate_qr = Confirm::with_theme(theme)
.with_prompt("Generate QR code for recovery? (for mobile access)")
.default(false)
.interact()?;
if self.state.generate_qr {
writeln!(term, " {} QR code will be generated", style("✓").green())?;
}
Ok(())
}
fn step_site_config(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
writeln!(
term,
"\n{}",
style("Step 4 of 9: Site Configuration").bold()
)?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
self.state.title = Input::with_theme(theme)
.with_prompt("Archive title")
.default(self.state.title.clone())
.interact_text()?;
writeln!(term, " {} Title: {}", style("✓").green(), self.state.title)?;
self.state.description = Input::with_theme(theme)
.with_prompt("Description (shown on unlock page)")
.default(self.state.description.clone())
.interact_text()?;
writeln!(term, " {} Description set", style("✓").green())?;
self.state.hide_metadata = Confirm::with_theme(theme)
.with_prompt("Hide workspace paths and file names? (for privacy)")
.default(false)
.interact()?;
if self.state.hide_metadata {
writeln!(term, " {} Metadata will be obfuscated", style("✓").green())?;
}
Ok(())
}
fn step_deployment_target(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
writeln!(term, "\n{}", style("Step 5 of 9: Deployment Target").bold())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
let targets = vec![
"Local export only (generate files)",
"GitHub Pages (requires gh CLI)",
"Cloudflare Pages (requires wrangler CLI)",
];
let target_selection = Select::with_theme(theme)
.with_prompt("Where would you like to deploy?")
.default(0)
.items(&targets)
.interact()?;
self.state.target = match target_selection {
1 => DeployTarget::GitHubPages,
2 => DeployTarget::CloudflarePages,
_ => DeployTarget::Local,
};
writeln!(
term,
" {} Target: {}",
style("✓").green(),
self.state.target
)?;
self.state.output_dir = PathBuf::from(
Input::<String>::with_theme(theme)
.with_prompt("Output directory")
.default("cass-export".to_string())
.interact_text()?,
);
writeln!(
term,
" {} Output: {}",
style("✓").green(),
self.state.output_dir.display()
)?;
if self.state.target != DeployTarget::Local {
let default_repo = format!("cass-archive-{}", chrono::Utc::now().format("%Y%m%d"));
let repo_name = Input::<String>::with_theme(theme)
.with_prompt("Repository/project name")
.default(default_repo)
.interact_text()?;
self.state.repo_name = Some(repo_name.clone());
writeln!(term, " {} Repo: {}", style("✓").green(), repo_name)?;
}
Ok(())
}
fn step_summary(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<bool> {
writeln!(
term,
"\n{}",
style("Step 6 of 9: Pre-Publish Summary").bold()
)?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
writeln!(term, "\n Generating summary...")?;
let summary = self.generate_prepublish_summary()?;
self.state.last_summary = Some(summary.clone());
writeln!(term, "\n{}", style("📊 CONTENT OVERVIEW").bold().cyan())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
writeln!(
term,
" Conversations: {}",
style(summary.total_conversations).green()
)?;
writeln!(
term,
" Messages: {}",
style(summary.total_messages).green()
)?;
writeln!(
term,
" Characters: {} (~{})",
summary.total_characters,
format_size(summary.total_characters)
)?;
writeln!(
term,
" Archive Size: ~{} (estimated, compressed + encrypted)",
style(format_size(summary.estimated_size_bytes)).yellow()
)?;
writeln!(term, "\n{}", style("📅 DATE RANGE").bold().cyan())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
if let (Some(earliest), Some(latest)) =
(&summary.earliest_timestamp, &summary.latest_timestamp)
{
let days = (*latest - *earliest).num_days();
writeln!(
term,
" From: {} To: {} ({} days)",
style(earliest.format("%Y-%m-%d")).white(),
style(latest.format("%Y-%m-%d")).white(),
days
)?;
if !summary.date_histogram.is_empty() {
let bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
let mut months: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for entry in &summary.date_histogram {
if entry.date.len() >= 7 {
let month = &entry.date[0..7];
*months.entry(month.to_string()).or_insert(0) += entry.message_count;
}
}
if !months.is_empty() {
let month_max = months.values().max().copied().unwrap_or(1).max(1);
let sparkline: String = months
.values()
.map(|&count| {
let idx = (count * 7 / month_max).min(7);
bars[idx]
})
.collect();
writeln!(term, " Activity: {}", style(sparkline).cyan())?;
}
}
} else {
writeln!(term, " No date information available")?;
}
writeln!(
term,
"\n{} ({})",
style("📁 WORKSPACES").bold().cyan(),
summary.workspaces.len()
)?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
for (_idx, ws) in summary.workspaces.iter().enumerate().take(5) {
let included_marker =
if ws.included && !self.state.exclusions.is_workspace_excluded(&ws.path) {
style("✓").green()
} else {
style("✗").red()
};
writeln!(
term,
" {} {} ({} conversations)",
included_marker,
style(&ws.display_name).white(),
ws.conversation_count
)?;
if !ws.sample_titles.is_empty() {
let titles: Vec<_> = ws
.sample_titles
.iter()
.take(2)
.map(|t| truncate_sample_title(t))
.collect();
writeln!(
term,
" {}",
style(format!("\"{}\"", titles.join("\", \""))).dim()
)?;
}
}
if summary.workspaces.len() > 5 {
writeln!(
term,
" {} and {} more...",
style("...").dim(),
summary.workspaces.len() - 5
)?;
}
writeln!(term, "\n{}", style("🤖 AGENTS").bold().cyan())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
for agent in &summary.agents {
writeln!(
term,
" • {}: {} conversations ({:.0}%)",
style(&agent.name).white(),
agent.conversation_count,
agent.percentage
)?;
}
writeln!(term, "\n{}", style("🔒 SECURITY").bold().cyan())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
if let Some(enc) = &summary.encryption_config {
writeln!(term, " Encryption: {}", enc.algorithm)?;
writeln!(term, " Key Derivation: {}", enc.key_derivation)?;
writeln!(term, " Key Slots: {}", enc.key_slot_count)?;
} else {
writeln!(term, " Encryption: AES-256-GCM")?;
writeln!(term, " Key Derivation: Argon2id")?;
}
let secret_status = if summary.secret_scan.total_findings == 0 {
style("✓ No secrets detected".to_string()).green()
} else if summary.secret_scan.has_critical {
style(format!(
"⚠️ {} issues (CRITICAL)",
summary.secret_scan.total_findings
))
.red()
} else {
style(format!(
"⚠️ {} issues found",
summary.secret_scan.total_findings
))
.yellow()
};
writeln!(term, " Secret Scan: {}", secret_status)?;
writeln!(term, "\n{}", style("⚙️ CONFIGURATION").bold().cyan())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
writeln!(term, " Title: {}", self.state.title)?;
writeln!(term, " Target: {}", self.state.target)?;
writeln!(term, " Output: {}", self.state.output_dir.display())?;
writeln!(
term,
" Recovery Key: {}",
if self.state.generate_recovery {
"Yes"
} else {
"No"
}
)?;
writeln!(
term,
" QR Code: {}",
if self.state.generate_qr { "Yes" } else { "No" }
)?;
let (ws_excluded, conv_excluded, pattern_excluded) =
self.state.exclusions.exclusion_counts();
if ws_excluded > 0 || conv_excluded > 0 || pattern_excluded > 0 {
writeln!(term, "\n{}", style("🚫 EXCLUSIONS").bold().yellow())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
if ws_excluded > 0 {
writeln!(term, " {} workspace(s) excluded", ws_excluded)?;
}
if conv_excluded > 0 {
writeln!(term, " {} conversation(s) excluded", conv_excluded)?;
}
if pattern_excluded > 0 {
writeln!(term, " {} pattern(s) active", pattern_excluded)?;
}
}
writeln!(term)?;
loop {
let options = vec![
"✓ Proceed with export",
"📁 View/Edit workspace exclusions",
"✗ Cancel export",
];
let selection = Select::with_theme(theme)
.with_prompt("What would you like to do?")
.items(&options)
.default(0)
.interact()?;
match selection {
0 => return Ok(true), 1 => {
self.edit_workspace_exclusions(term, theme, &summary)?;
}
2 => return Ok(false), _ => unreachable!(),
}
}
}
fn generate_prepublish_summary(&self) -> Result<PrePublishSummary> {
let conn = Connection::open(self.state.db_path.to_string_lossy().as_ref())
.context("Failed to open database for summary generation")?;
conn.execute_batch(
"PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;",
)
.context("Failed to set PRAGMAs for summary generation")?;
let since_ts = self
.state
.time_range
.as_deref()
.and_then(crate::ui::time_parser::parse_time_input);
let filters = SummaryFilters {
agents: if self.state.agents.is_empty() {
None
} else {
Some(self.state.agents.clone())
},
workspaces: self
.state
.workspaces
.as_ref()
.map(|ws| ws.iter().map(|p| p.to_string_lossy().to_string()).collect()),
since_ts,
until_ts: None,
};
let generator = SummaryGenerator::new(&conn);
let summary = generator.generate_with_exclusions(Some(&filters), &self.state.exclusions)?;
Ok(summary)
}
fn edit_workspace_exclusions(
&mut self,
term: &mut Term,
theme: &ColorfulTheme,
summary: &PrePublishSummary,
) -> Result<()> {
writeln!(term, "\n{}", style("Workspace Exclusions").bold())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
if summary.workspaces.is_empty() {
writeln!(term, " No workspaces to configure.")?;
return Ok(());
}
let items: Vec<String> = summary
.workspaces
.iter()
.map(|ws| {
format!(
"{} ({} conversations)",
ws.display_name, ws.conversation_count
)
})
.collect();
let defaults: Vec<bool> = summary
.workspaces
.iter()
.map(|ws| !self.state.exclusions.is_workspace_excluded(&ws.path))
.collect();
let selections = MultiSelect::with_theme(theme)
.with_prompt("Select workspaces to INCLUDE (unselected will be excluded)")
.items(&items)
.defaults(&defaults)
.interact()?;
for (idx, ws) in summary.workspaces.iter().enumerate() {
if selections.contains(&idx) {
self.state.exclusions.include_workspace(&ws.path);
} else {
self.state.exclusions.exclude_workspace(&ws.path);
}
}
let (ws_excluded, _, _) = self.state.exclusions.exclusion_counts();
writeln!(
term,
" {} {} workspace(s) now excluded",
style("✓").green(),
ws_excluded
)?;
Ok(())
}
fn step_confirmation(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<bool> {
writeln!(
term,
"\n{}",
style("Step 7 of 9: Safety Confirmation").bold()
)?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
let target_domain = if self.state.target != DeployTarget::Local {
self.state
.repo_name
.as_ref()
.map(|name| match self.state.target {
DeployTarget::GitHubPages => format!("{}.github.io", name),
DeployTarget::CloudflarePages => format!("{}.pages.dev", name),
DeployTarget::Local => String::new(),
})
} else {
None
};
let summary = if let Some(summary) = self.state.last_summary.clone() {
summary
} else {
let generated = self
.generate_prepublish_summary()
.context("Failed to generate pre-publish summary for confirmation")?;
self.state.last_summary = Some(generated.clone());
generated
};
let config = ConfirmationConfig {
has_secrets: self.state.secret_scan_has_findings,
has_critical_secrets: self.state.secret_scan_has_critical,
secret_count: self.state.secret_scan_count,
target_domain,
is_remote_publish: self.state.target != DeployTarget::Local,
password_entropy_bits: self.state.password_entropy_bits,
has_recovery_key: self.state.generate_recovery,
recovery_key_phrase: None, summary,
};
let mut flow = ConfirmationFlow::new(config);
loop {
match flow.current_step() {
ConfirmationStep::SecretScanAcknowledgment => {
if !self.confirm_secret_ack(term, theme, &flow)? {
return Ok(false);
}
flow.complete_current_step();
}
ConfirmationStep::ContentReview => {
if !self.confirm_content_review(term, theme, &flow)? {
return Ok(false);
}
flow.complete_current_step();
}
ConfirmationStep::PublicPublishingWarning => {
if !self.confirm_public_warning(term, theme, &flow)? {
return Ok(false);
}
flow.complete_current_step();
}
ConfirmationStep::PasswordStrengthWarning => {
match self.confirm_password_strength(term, theme, &mut flow)? {
PasswordStrengthAction::SetStronger => {
writeln!(
term,
"\n {} Returning to security configuration...",
style("←").cyan()
)?;
return Ok(false);
}
PasswordStrengthAction::ProceedAnyway => {
flow.complete_current_step();
}
PasswordStrengthAction::Abort => {
return Ok(false);
}
}
}
ConfirmationStep::RecoveryKeyBackup => {
if !self.confirm_recovery_key(term, theme, &flow)? {
return Ok(false);
}
flow.complete_current_step();
}
ConfirmationStep::FinalConfirmation => {
if !self.confirm_final(term, theme, &mut flow)? {
return Ok(false);
}
flow.complete_current_step();
break;
}
}
}
writeln!(
term,
"\n {} All safety checks completed",
style("✓").green()
)?;
Ok(true)
}
fn confirm_secret_ack(
&self,
term: &mut Term,
theme: &ColorfulTheme,
flow: &ConfirmationFlow,
) -> Result<bool> {
writeln!(
term,
"\n {}",
style("⚠️ SECRETS DETECTED").yellow().bold()
)?;
writeln!(term)?;
writeln!(
term,
" The secret scan found {} potential sensitive data item(s).",
flow.config().secret_count
)?;
writeln!(term)?;
writeln!(
term,
" Even though the export will be encrypted, publishing content"
)?;
writeln!(term, " containing secrets carries additional risk:")?;
writeln!(term)?;
writeln!(
term,
" {} If your password is weak, secrets could be exposed",
style("⚠").yellow()
)?;
writeln!(
term,
" {} Secrets may remain valid if encryption is ever compromised",
style("⚠").yellow()
)?;
writeln!(term)?;
loop {
let input: String = Input::with_theme(theme)
.with_prompt("Type \"I understand the risks\" to proceed (or \"abort\" to cancel)")
.interact_text()?;
if input.trim().to_lowercase() == "abort" {
return Ok(false);
}
match flow.validate_secret_ack(&input) {
StepValidation::Passed => {
writeln!(term, " {} Secrets acknowledged", style("✓").green())?;
return Ok(true);
}
StepValidation::Failed(msg) => {
writeln!(term, " {} {}", style("✗").red(), msg)?;
}
}
}
}
fn confirm_content_review(
&self,
term: &mut Term,
theme: &ColorfulTheme,
flow: &ConfirmationFlow,
) -> Result<bool> {
writeln!(term, "\n {}", style("📋 CONTENT REVIEW").cyan().bold())?;
writeln!(term)?;
writeln!(term, " You are about to export:")?;
writeln!(term)?;
writeln!(
term,
" • {} conversations from {} workspaces",
flow.config().summary.total_conversations,
flow.config().summary.workspaces.len()
)?;
writeln!(
term,
" • {} messages",
flow.config().summary.total_messages
)?;
writeln!(
term,
" • Content from: {}",
flow.config()
.summary
.agents
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)?;
writeln!(term)?;
let confirmed = Confirm::with_theme(theme)
.with_prompt("Have you reviewed the content summary?")
.default(false)
.interact()?;
if confirmed {
writeln!(term, " {} Content reviewed", style("✓").green())?;
}
Ok(confirmed)
}
fn confirm_public_warning(
&self,
term: &mut Term,
theme: &ColorfulTheme,
flow: &ConfirmationFlow,
) -> Result<bool> {
let domain = flow
.config()
.target_domain
.as_deref()
.unwrap_or("your-site");
writeln!(
term,
"\n {}",
style("🌐 PUBLIC PUBLISHING WARNING").yellow().bold()
)?;
writeln!(term)?;
writeln!(term, " You are about to publish to:")?;
writeln!(term, " {}", style(format!("https://{}/", domain)).cyan())?;
writeln!(term)?;
writeln!(
term,
" {} This URL will be publicly accessible on the internet",
style("⚠").yellow()
)?;
writeln!(
term,
" {} Anyone with the URL can download the encrypted archive",
style("⚠").yellow()
)?;
writeln!(
term,
" {} The security depends entirely on your password strength",
style("⚠").yellow()
)?;
writeln!(term)?;
loop {
let input: String = Input::with_theme(theme)
.with_prompt(format!(
"Type \"publish to {}\" to confirm (or \"abort\" to cancel)",
domain
))
.interact_text()?;
if input.trim().to_lowercase() == "abort" {
return Ok(false);
}
match flow.validate_public_warning(&input) {
StepValidation::Passed => {
writeln!(term, " {} Public URL confirmed", style("✓").green())?;
return Ok(true);
}
StepValidation::Failed(msg) => {
writeln!(term, " {} {}", style("✗").red(), msg)?;
}
}
}
}
fn confirm_password_strength(
&self,
term: &mut Term,
theme: &ColorfulTheme,
flow: &mut ConfirmationFlow,
) -> Result<PasswordStrengthAction> {
writeln!(
term,
"\n {}",
style("🔐 PASSWORD STRENGTH WARNING").yellow().bold()
)?;
writeln!(term)?;
writeln!(
term,
" Your password has estimated entropy of {:.0} bits.",
self.state.password_entropy_bits
)?;
writeln!(term)?;
writeln!(term, " Recommended minimum: 60 bits")?;
writeln!(term)?;
writeln!(
term,
" A password with low entropy could potentially be cracked"
)?;
writeln!(
term,
" by a determined attacker with sufficient resources."
)?;
writeln!(term)?;
writeln!(term, " For long-term security, consider:")?;
writeln!(term, " • Using a longer password (16+ characters)")?;
writeln!(term, " • Including numbers, symbols, and mixed case")?;
writeln!(term, " • Using a passphrase of 5+ random words")?;
writeln!(term)?;
let options = vec![
"[S] Set a stronger password",
"[P] Proceed with current password (not recommended)",
"[A] Abort export",
];
let selection = Select::with_theme(theme)
.with_prompt("What would you like to do?")
.items(&options)
.default(0)
.interact()?;
let action = match selection {
0 => PasswordStrengthAction::SetStronger,
1 => {
writeln!(
term,
" {} Password warning acknowledged",
style("⚠").yellow()
)?;
PasswordStrengthAction::ProceedAnyway
}
_ => PasswordStrengthAction::Abort,
};
flow.set_password_action(action);
Ok(action)
}
fn confirm_recovery_key(
&self,
term: &mut Term,
theme: &ColorfulTheme,
_flow: &ConfirmationFlow,
) -> Result<bool> {
writeln!(
term,
"\n {}",
style("💾 RECOVERY KEY BACKUP").cyan().bold()
)?;
writeln!(term)?;
writeln!(
term,
" A recovery key will be generated. This is the ONLY way"
)?;
writeln!(term, " to recover your data if you forget your password.")?;
writeln!(term)?;
writeln!(
term,
" {} If you lose both your password AND the recovery key,",
style("⚠").yellow()
)?;
writeln!(term, " your data will be permanently inaccessible.")?;
writeln!(term)?;
let confirmed = Confirm::with_theme(theme)
.with_prompt("I understand that I must save the recovery key securely")
.default(false)
.interact()?;
if confirmed {
writeln!(
term,
" {} Recovery key backup confirmed",
style("✓").green()
)?;
}
Ok(confirmed)
}
fn confirm_final(
&self,
term: &mut Term,
theme: &ColorfulTheme,
flow: &mut ConfirmationFlow,
) -> Result<bool> {
writeln!(term, "\n {}", style("✓ FINAL CONFIRMATION").green().bold())?;
writeln!(term)?;
writeln!(term, " Ready to publish:")?;
writeln!(term)?;
for (_, label) in flow.completed_steps_summary() {
writeln!(term, " {} {}", style("✓").green(), label)?;
}
writeln!(term)?;
if self.state.target != DeployTarget::Local {
if let Some(domain) = &flow.config().target_domain {
writeln!(term, " Target: https://{}/", domain)?;
}
} else {
writeln!(
term,
" Target: {} (local)",
self.state.output_dir.display()
)?;
}
if let Some(summary) = &self.state.last_summary {
writeln!(
term,
" Size: ~{}",
format_size(summary.estimated_size_bytes)
)?;
}
writeln!(term)?;
writeln!(
term,
" {}",
style("Press Enter TWICE to confirm and begin export").dim()
)?;
writeln!(term)?;
let _: String = Input::with_theme(theme)
.with_prompt("[First confirmation - press Enter]")
.allow_empty(true)
.interact_text()?;
flow.process_final_enter();
writeln!(term, " {} First confirmation received", style("•").cyan())?;
let _: String = Input::with_theme(theme)
.with_prompt("[Second confirmation - press Enter to proceed]")
.allow_empty(true)
.interact_text()?;
flow.process_final_enter();
writeln!(
term,
" {} Second confirmation received",
style("✓").green()
)?;
Ok(true)
}
fn step_export(&mut self, term: &mut Term) -> Result<()> {
writeln!(term, "\n{}", style("Step 8 of 9: Export Progress").bold())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
writeln!(term, "\n Estimating export size...")?;
let since_ts = self
.state
.time_range
.as_deref()
.and_then(crate::ui::time_parser::parse_time_input);
let agents: Vec<String> = self.state.agents.to_vec();
let estimate = SizeEstimate::from_database(
&self.state.db_path,
if agents.is_empty() {
None
} else {
Some(&agents)
},
since_ts,
None,
)?;
writeln!(term)?;
for line in estimate.format_display().lines() {
writeln!(term, " {}", line)?;
}
writeln!(term)?;
match estimate.check_limits() {
SizeLimitResult::Ok => {
writeln!(term, " {} Size within limits", style("✓").green())?;
}
SizeLimitResult::Warning(warning) => {
writeln!(term, " {} {}", style("⚠").yellow(), warning)?;
writeln!(term)?;
let theme = ColorfulTheme::default();
if !Confirm::with_theme(&theme)
.with_prompt("Continue with export?")
.default(true)
.interact()?
{
bail!("Export cancelled due to size warning");
}
}
SizeLimitResult::ExceedsLimit(error) => {
writeln!(term)?;
writeln!(term, " {} {}", style("✗").red(), error)?;
writeln!(term)?;
bail!("Export blocked: {}", error);
}
}
writeln!(term)?;
let staging_dir = tempfile::tempdir()?;
let export_db_path = staging_dir.path().join("export.db");
let encrypted_dir = staging_dir.path().join("encrypted");
std::fs::create_dir_all(&encrypted_dir)?;
let pb = ProgressBar::new_spinner();
let spinner_style = ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.context("build progress spinner style for export phase")?;
pb.set_style(spinner_style);
pb.enable_steady_tick(Duration::from_millis(100));
pb.set_message("Filtering and exporting conversations...");
let workspaces = self.state.workspaces.clone();
let since_dt = self.state.time_range.as_deref().and_then(|s| {
crate::ui::time_parser::parse_time_input(s)
.and_then(chrono::DateTime::from_timestamp_millis)
});
let filter = ExportFilter {
agents: Some(self.state.agents.clone()),
workspaces,
since: since_dt,
until: None,
path_mode: if self.state.hide_metadata {
PathMode::Hash
} else {
PathMode::Relative
},
};
let engine = ExportEngine::new(&self.state.db_path, &export_db_path, filter);
let running = Arc::new(AtomicBool::new(true));
let stats = engine.execute(
|current, total| {
if total > 0 {
pb.set_message(format!("Exporting... {}/{} conversations", current, total));
}
},
Some(running),
)?;
pb.finish_with_message(format!(
"✓ Exported {} conversations, {} messages",
stats.conversations_processed, stats.messages_processed
));
if self.no_encryption_mode {
writeln!(term)?;
writeln!(
term,
" {} Skipping encryption (unencrypted mode)",
style("⚠").yellow()
)?;
writeln!(
term,
" {}",
style("WARNING: All content will be publicly readable!").red()
)?;
let payload_dir = encrypted_dir.join("payload");
std::fs::create_dir_all(&payload_dir)?;
let dest_db = payload_dir.join("data.db");
std::fs::copy(&export_db_path, &dest_db)?;
let db_size = std::fs::metadata(&dest_db).map(|m| m.len()).unwrap_or(0);
let config = unencrypted_bundle_config(db_size);
let config_path = encrypted_dir.join("config.json");
crate::pages::write_file_durably(
&config_path,
serde_json::to_string_pretty(&config)?.as_bytes(),
)?;
} else {
let pb2 = ProgressBar::new_spinner();
let spinner_style = ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.context("build progress spinner style for encryption phase")?;
pb2.set_style(spinner_style);
pb2.enable_steady_tick(Duration::from_millis(100));
pb2.set_message("Encrypting archive...");
let mut enc_engine = EncryptionEngine::default();
if let Some(password) = &self.state.password {
enc_engine.add_password_slot(password)?;
}
if self.state.generate_recovery {
let mut recovery_bytes = [0u8; 32];
use rand::Rng;
let mut rng = rand::rng();
rng.fill_bytes(&mut recovery_bytes);
enc_engine.add_recovery_slot(&recovery_bytes)?;
self.state.recovery_secret = Some(recovery_bytes.to_vec());
}
if enc_engine.key_slot_count() == 0 {
bail!(
"No encryption key slots configured — archive would be permanently undecryptable"
);
}
enc_engine.encrypt_file(&export_db_path, &encrypted_dir, |_, _| {})?;
pb2.finish_with_message("✓ Encryption complete");
}
let pb3 = ProgressBar::new_spinner();
let spinner_style = ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.context("build progress spinner style for bundle phase")?;
pb3.set_style(spinner_style);
pb3.enable_steady_tick(Duration::from_millis(100));
pb3.set_message("Building static site bundle...");
let generated_docs = if let Some(ref summary) = self.state.last_summary {
let target_url = match self.state.target {
DeployTarget::GitHubPages => None, DeployTarget::CloudflarePages => self
.state
.repo_name
.as_ref()
.map(|name| format!("https://{}.pages.dev", name)),
DeployTarget::Local => None,
};
let doc_config = if let Some(url) = target_url {
DocConfig::new().with_url(url)
} else {
DocConfig::new()
};
let doc_generator = DocumentationGenerator::new(doc_config, summary.clone());
doc_generator.generate_all()
} else {
Vec::new()
};
let bundle_config = BundleConfig {
title: self.state.title.clone(),
description: self.state.description.clone(),
hide_metadata: self.state.hide_metadata,
recovery_secret: self.state.recovery_secret.clone(),
generate_qr: self.state.generate_qr,
generated_docs,
};
let builder = BundleBuilder::with_config(bundle_config);
let bundle_result =
builder.build(&encrypted_dir, &self.state.output_dir, |phase, msg| {
pb3.set_message(format!("{}: {}", phase, msg));
})?;
self.state.final_site_dir = Some(bundle_result.site_dir.clone());
pb3.finish_with_message(format!(
"✓ Bundle complete: {} files, fingerprint {}",
bundle_result.total_files,
bundle_result
.fingerprint
.get(..8)
.unwrap_or(&bundle_result.fingerprint)
));
let warnings = BundleVerifier::verify(&bundle_result.site_dir)?;
if !warnings.is_empty() {
writeln!(term)?;
writeln!(term, " {} Size warnings:", style("⚠").yellow())?;
for warning in &warnings {
writeln!(term, " {}", warning)?;
}
}
writeln!(term)?;
writeln!(
term,
" {} Site directory (deploy this): {}",
style("✓").green(),
style(bundle_result.site_dir.display()).cyan()
)?;
writeln!(
term,
" {} Private directory (keep secure): {}",
style("✓").green(),
style(bundle_result.private_dir.display()).cyan()
)?;
writeln!(
term,
" {} Integrity fingerprint: {}",
style("✓").green(),
style(&bundle_result.fingerprint).cyan()
)?;
if self.state.recovery_secret.is_some() {
writeln!(term)?;
writeln!(
term,
" {} Recovery secret saved to: {}",
style("⚠").yellow().bold(),
style(
bundle_result
.private_dir
.join("recovery-secret.txt")
.display()
)
.cyan()
)?;
writeln!(
term,
" {}",
style("Store this file securely - it can unlock your archive if you forget the password.").dim()
)?;
}
if self.state.generate_qr {
writeln!(
term,
" {} QR codes saved to private directory",
style("✓").green()
)?;
}
Ok(())
}
fn deploy_site_dir(&self) -> PathBuf {
self.state
.final_site_dir
.as_ref()
.cloned()
.unwrap_or_else(|| self.state.output_dir.join("site"))
}
fn deploy_project_name(&self) -> String {
self.state
.repo_name
.clone()
.unwrap_or_else(|| "cass-archive".to_string())
}
fn step_deploy(&self, term: &mut Term) -> Result<()> {
writeln!(term, "\n{}", style("Step 9 of 9: Deployment").bold())?;
writeln!(term, "{}", style("─".repeat(40)).dim())?;
match self.state.target {
DeployTarget::Local => {
let site_dir = self.deploy_site_dir();
writeln!(term)?;
writeln!(term, "{}", style("✓ Export complete!").green().bold())?;
writeln!(term)?;
writeln!(
term,
"Your archive bundle has been exported to: {}",
style(self.state.output_dir.display()).cyan()
)?;
writeln!(term)?;
writeln!(
term,
"Deployable site directory: {}",
style(site_dir.display()).cyan()
)?;
writeln!(term)?;
writeln!(term, "To preview locally, run:")?;
writeln!(
term,
" {}",
style(format!(
"cass pages --preview {} --no-open",
site_dir.display()
))
.dim()
)?;
writeln!(term)?;
writeln!(
term,
"Then open {} in your browser.",
style("http://localhost:8080").cyan()
)?;
}
DeployTarget::GitHubPages => {
writeln!(term, " {} GitHub Pages deployment...", style("→").cyan())?;
let site_dir = self.deploy_site_dir();
let repo_name = self.deploy_project_name();
let deployer = GitHubDeployer::new(repo_name.clone());
match deployer.check_prerequisites() {
Ok(prereqs) if prereqs.is_ready() => {
match deployer.deploy(&site_dir, |_phase, msg| {
let _ = writeln!(term, " {} {}", style("•").dim(), msg);
}) {
Ok(result) => {
writeln!(term)?;
writeln!(
term,
" {} Deployed to GitHub Pages!",
style("✓").green().bold()
)?;
writeln!(term)?;
writeln!(term, " Repository: {}", style(&result.repo_url).cyan())?;
writeln!(
term,
" Your archive is available at: {}",
style(&result.pages_url).cyan().bold()
)?;
}
Err(e) => {
writeln!(term)?;
writeln!(term, " {} Deployment failed: {}", style("✗").red(), e)?;
writeln!(term)?;
writeln!(
term,
"To deploy manually, push the {} directory to a gh-pages branch.",
site_dir.display()
)?;
}
}
}
Ok(prereqs) => {
let missing = prereqs.missing();
writeln!(term)?;
writeln!(term, " {} Prerequisites not met:", style("⚠").yellow())?;
for item in &missing {
writeln!(term, " {} {}", style("•").dim(), item)?;
}
writeln!(term)?;
writeln!(
term,
"Please install/configure the missing tools and try again."
)?;
writeln!(
term,
"To deploy manually after fixing prerequisites, push the {} directory to a gh-pages branch.",
site_dir.display()
)?;
}
Err(e) => {
writeln!(term)?;
writeln!(
term,
" {} Could not check prerequisites: {}",
style("⚠").yellow(),
e
)?;
writeln!(term)?;
writeln!(
term,
"To deploy manually, push the {} directory to a gh-pages branch.",
site_dir.display()
)?;
}
}
}
DeployTarget::CloudflarePages => {
writeln!(
term,
" {} Cloudflare Pages deployment...",
style("→").cyan()
)?;
let site_dir = self.deploy_site_dir();
let project_name = self.deploy_project_name();
let deployer = CloudflareDeployer::new(CloudflareConfig {
project_name: project_name.clone(),
custom_domain: None,
create_if_missing: true,
branch: "main".to_string(),
account_id: dotenvy::var("CLOUDFLARE_ACCOUNT_ID").ok(),
api_token: dotenvy::var("CLOUDFLARE_API_TOKEN").ok(),
});
match deployer.check_prerequisites() {
Ok(prereqs) if prereqs.is_ready() => {
match deployer.deploy(&site_dir, |_phase, msg| {
let _ = writeln!(term, " {} {}", style("•").dim(), msg);
}) {
Ok(result) => {
writeln!(term)?;
writeln!(
term,
" {} Deployed to Cloudflare Pages!",
style("✓").green().bold()
)?;
writeln!(term)?;
writeln!(
term,
" Your archive is available at: {}",
style(&result.pages_url).cyan().bold()
)?;
if let Some(ref domain) = result.custom_domain {
writeln!(term, " Custom domain: {}", style(domain).cyan())?;
}
}
Err(e) => {
writeln!(term)?;
writeln!(term, " {} Deployment failed: {}", style("✗").red(), e)?;
writeln!(term)?;
writeln!(
term,
"To deploy manually, use wrangler to deploy the {} directory:",
site_dir.display()
)?;
writeln!(
term,
" {}",
style(format!(
"wrangler pages deploy {} --project-name {}",
site_dir.display(),
project_name
))
.dim()
)?;
}
}
}
Ok(prereqs) => {
let missing = prereqs.missing();
writeln!(term)?;
writeln!(term, " {} Prerequisites not met:", style("⚠").yellow())?;
for item in &missing {
writeln!(term, " {} {}", style("•").dim(), item)?;
}
writeln!(term)?;
writeln!(term, "To deploy manually after meeting prerequisites:")?;
writeln!(
term,
" {}",
style(format!(
"wrangler pages deploy {} --project-name {}",
site_dir.display(),
project_name
))
.dim()
)?;
}
Err(e) => {
writeln!(term)?;
writeln!(
term,
" {} Could not check prerequisites: {}",
style("⚠").yellow(),
e
)?;
writeln!(term)?;
writeln!(
term,
"To deploy manually, use wrangler to deploy the {} directory:",
site_dir.display()
)?;
writeln!(
term,
" {}",
style(format!(
"wrangler pages deploy {} --project-name {}",
site_dir.display(),
project_name
))
.dim()
)?;
}
}
}
}
writeln!(term)?;
Ok(())
}
}
fn unencrypted_bundle_config(db_size: u64) -> serde_json::Value {
serde_json::json!({
"encrypted": false,
"version": "1.0.0",
"payload": {
"path": "payload/data.db",
"format": "sqlite",
"size_bytes": db_size
},
"warning": "UNENCRYPTED - All content is publicly readable"
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deploy_target_display() {
assert_eq!(DeployTarget::Local.to_string(), "Local export only");
assert_eq!(DeployTarget::GitHubPages.to_string(), "GitHub Pages");
assert_eq!(
DeployTarget::CloudflarePages.to_string(),
"Cloudflare Pages"
);
}
#[test]
fn deploy_target_equality() {
assert_eq!(DeployTarget::Local, DeployTarget::Local);
assert_eq!(DeployTarget::GitHubPages, DeployTarget::GitHubPages);
assert_eq!(DeployTarget::CloudflarePages, DeployTarget::CloudflarePages);
assert_ne!(DeployTarget::Local, DeployTarget::GitHubPages);
assert_ne!(DeployTarget::GitHubPages, DeployTarget::CloudflarePages);
}
#[test]
fn deploy_target_clone() {
let target = DeployTarget::CloudflarePages;
let cloned = target;
assert_eq!(target, cloned);
}
#[test]
fn unencrypted_bundle_config_shape() {
let config = unencrypted_bundle_config(1234);
assert_eq!(
config,
serde_json::json!({
"encrypted": false,
"version": "1.0.0",
"payload": {
"path": "payload/data.db",
"format": "sqlite",
"size_bytes": 1234
},
"warning": "UNENCRYPTED - All content is publicly readable"
})
);
}
#[test]
fn wizard_state_default_values() {
let state = WizardState::default();
assert!(state.agents.is_empty());
assert!(state.time_range.is_none());
assert!(state.workspaces.is_none());
assert!(state.password.is_none());
assert!(state.recovery_secret.is_none());
assert!(state.generate_recovery); assert!(!state.generate_qr);
assert_eq!(state.title, "cass Archive");
assert_eq!(
state.description,
"Encrypted archive of AI coding agent conversations"
);
assert!(!state.hide_metadata);
assert_eq!(state.target, DeployTarget::Local);
assert_eq!(state.output_dir, PathBuf::from("cass-export"));
assert!(state.repo_name.is_none());
assert_eq!(state.exclusions.exclusion_counts(), (0, 0, 0));
assert!(state.last_summary.is_none());
assert!(!state.secret_scan_has_findings);
assert!(!state.secret_scan_has_critical);
assert_eq!(state.secret_scan_count, 0);
assert_eq!(state.password_entropy_bits, 0.0);
assert!(!state.no_encryption);
assert!(!state.unencrypted_confirmed);
}
#[test]
fn wizard_state_db_path_is_set() {
let state = WizardState::default();
assert!(state.db_path.to_string_lossy().contains("agent_search.db"));
}
#[test]
fn wizard_state_clone() {
let state = WizardState {
title: "Custom Title".to_string(),
agents: vec!["claude".to_string(), "codex".to_string()],
no_encryption: true,
..Default::default()
};
let cloned = state.clone();
assert_eq!(cloned.title, "Custom Title");
assert_eq!(
cloned.agents,
vec!["claude".to_string(), "codex".to_string()]
);
assert!(cloned.no_encryption);
}
#[test]
fn pages_wizard_new_initializes_default_state() {
let wizard = PagesWizard::new();
assert!(!wizard.no_encryption_mode);
}
#[test]
fn pages_wizard_default_impl() {
let wizard1 = PagesWizard::new();
let wizard2 = PagesWizard::default();
assert_eq!(wizard1.no_encryption_mode, wizard2.no_encryption_mode);
}
#[test]
fn pages_wizard_set_no_encryption() {
let mut wizard = PagesWizard::new();
assert!(!wizard.no_encryption_mode);
assert!(!wizard.state.no_encryption);
wizard.set_no_encryption(true);
assert!(wizard.no_encryption_mode);
assert!(wizard.state.no_encryption);
wizard.set_no_encryption(false);
assert!(!wizard.no_encryption_mode);
assert!(!wizard.state.no_encryption);
}
#[test]
fn time_range_selection_mapping() {
fn map_time_selection(selection: usize) -> Option<String> {
match selection {
1 => Some("-7d".to_string()),
2 => Some("-30d".to_string()),
3 => Some("-90d".to_string()),
4 => Some("-365d".to_string()),
_ => None,
}
}
assert_eq!(map_time_selection(0), None);
assert_eq!(map_time_selection(1), Some("-7d".to_string()));
assert_eq!(map_time_selection(2), Some("-30d".to_string()));
assert_eq!(map_time_selection(3), Some("-90d".to_string()));
assert_eq!(map_time_selection(4), Some("-365d".to_string()));
assert_eq!(map_time_selection(5), None);
}
#[test]
fn deploy_target_selection_mapping() {
fn map_target_selection(selection: usize) -> DeployTarget {
match selection {
1 => DeployTarget::GitHubPages,
2 => DeployTarget::CloudflarePages,
_ => DeployTarget::Local,
}
}
assert_eq!(map_target_selection(0), DeployTarget::Local);
assert_eq!(map_target_selection(1), DeployTarget::GitHubPages);
assert_eq!(map_target_selection(2), DeployTarget::CloudflarePages);
assert_eq!(map_target_selection(3), DeployTarget::Local);
}
#[test]
fn wizard_state_agents_modification() {
let mut state = WizardState::default();
assert!(state.agents.is_empty());
state.agents = vec!["claude".to_string()];
assert_eq!(state.agents.len(), 1);
state.agents.push("codex".to_string());
assert_eq!(state.agents.len(), 2);
assert_eq!(
state.agents,
vec!["claude".to_string(), "codex".to_string()]
);
}
#[test]
fn wizard_state_workspaces_modification() {
let mut state = WizardState::default();
assert!(state.workspaces.is_none());
state.workspaces = Some(vec![PathBuf::from("/project1")]);
assert_eq!(state.workspaces.as_ref().unwrap().len(), 1);
state
.workspaces
.as_mut()
.unwrap()
.push(PathBuf::from("/project2"));
assert_eq!(state.workspaces.as_ref().unwrap().len(), 2);
}
#[test]
fn wizard_state_security_configuration() {
let state = WizardState {
password: Some("test_password".to_string()),
recovery_secret: Some(vec![1, 2, 3, 4]),
generate_recovery: false,
generate_qr: true,
..Default::default()
};
assert_eq!(state.password, Some("test_password".to_string()));
assert_eq!(state.recovery_secret, Some(vec![1, 2, 3, 4]));
assert!(!state.generate_recovery);
assert!(state.generate_qr);
}
#[test]
fn wizard_state_debug_redacts_sensitive_fields() {
let state = WizardState {
password: Some("test_password".to_string()),
recovery_secret: Some(vec![1, 2, 3, 4]),
cloudflare_api_token: Some("cf-secret-token".to_string()),
..Default::default()
};
let debug = format!("{state:?}");
assert!(debug.contains("password"));
assert!(debug.contains("recovery_secret"));
assert!(debug.contains("cloudflare_api_token"));
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("test_password"));
assert!(!debug.contains("cf-secret-token"));
assert!(!debug.contains("[1, 2, 3, 4]"));
}
#[test]
fn sample_title_truncation_is_utf8_boundary_safe() {
let ascii = "abcdefghijklmnopqrstuvwxyz0123456789";
assert_eq!(
truncate_sample_title(ascii),
"abcdefghijklmnopqrstuvwxyz0..."
);
let unicode = format!("{}{}", "日本語".repeat(12), "suffix");
let truncated = truncate_sample_title(&unicode);
assert!(truncated.ends_with("..."));
assert!(truncated.is_char_boundary(truncated.len()));
assert!(truncated.len() <= 30);
}
#[test]
fn wizard_state_password_entropy() {
let mut state = WizardState::default();
assert_eq!(state.password_entropy_bits, 0.0);
state.password_entropy_bits = 64.5;
assert!((state.password_entropy_bits - 64.5).abs() < f64::EPSILON);
}
#[test]
fn wizard_state_secret_scan_results() {
let state = WizardState {
secret_scan_has_findings: true,
secret_scan_has_critical: true,
secret_scan_count: 5,
..Default::default()
};
assert!(state.secret_scan_has_findings);
assert!(state.secret_scan_has_critical);
assert_eq!(state.secret_scan_count, 5);
}
#[test]
fn wizard_state_output_configuration() {
let state = WizardState {
output_dir: PathBuf::from("/custom/output"),
repo_name: Some("my-archive".to_string()),
..Default::default()
};
assert_eq!(state.output_dir, PathBuf::from("/custom/output"));
assert_eq!(state.repo_name, Some("my-archive".to_string()));
}
#[test]
fn wizard_state_with_unicode_values() {
let state = WizardState {
title: "日本語タイトル".to_string(),
description: "説明文 with émojis 🎉".to_string(),
agents: vec!["クローード".to_string()],
..Default::default()
};
assert_eq!(state.title, "日本語タイトル");
assert_eq!(state.description, "説明文 with émojis 🎉");
assert_eq!(state.agents[0], "クローード");
}
#[test]
fn wizard_state_empty_strings() {
let state = WizardState {
title: "".to_string(),
description: "".to_string(),
..Default::default()
};
assert!(state.title.is_empty());
assert!(state.description.is_empty());
}
}