hyperstack-cli 0.6.5

CLI tool for generating TypeScript SDKs from HyperStack stream specifications
use anyhow::Result;
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;

use crate::api_client::{ApiClient, BuildStatus, CreateBuildRequest, DEFAULT_DOMAIN_SUFFIX};
use crate::config::{resolve_stacks_to_push, HyperstackConfig};
use crate::telemetry;
use crate::ui;

fn generate_short_uuid() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    format!("{:08x}", (timestamp & 0xFFFFFFFF) as u32)
}

pub fn up(
    config_path: &str,
    stack_name: Option<&str>,
    branch: Option<String>,
    preview: bool,
    dry_run: bool,
) -> Result<()> {
    let start = std::time::Instant::now();
    let config = HyperstackConfig::load_optional(config_path)?;

    let branch = if preview {
        Some(format!("preview-{}", generate_short_uuid()))
    } else {
        branch
    };

    let stacks = resolve_stacks_to_push(config.as_ref(), stack_name)?;

    if stacks.is_empty() {
        anyhow::bail!("No stacks found to deploy");
    }

    if dry_run {
        return show_dry_run(&stacks, branch.as_deref());
    }

    let client = ApiClient::new()?;

    if stacks.len() > 1 && stack_name.is_none() {
        println!(
            "{} Found {} stacks. Deploying all...\n",
            ui::symbols::ARROW.blue().bold(),
            stacks.len()
        );
    }

    for ast in &stacks {
        deploy_single_stack(&client, ast, branch.as_deref())?;
        println!();
    }

    telemetry::record_stack_deployed(stack_name.unwrap_or(""), start.elapsed());

    Ok(())
}

fn show_dry_run(stacks: &[crate::config::DiscoveredAst], branch: Option<&str>) -> Result<()> {
    ui::print_section("Dry Run - No changes will be made");
    println!();

    println!(
        "{} Would deploy {} stack(s):",
        ui::symbols::ARROW.blue().bold(),
        stacks.len()
    );
    println!();

    let client = ApiClient::new().ok();

    for ast in stacks {
        println!(
            "  {} {}",
            ui::symbols::BULLET.dimmed(),
            ast.stack_name.green().bold()
        );
        println!("    Stack: {}", ast.stack_id);
        println!("    Stack: {}", ast.path.display());
        if !ast.program_ids.is_empty() {
            println!("    Program IDs: {}", ast.program_ids.join(", "));
        }

        let url = get_expected_url(&client, &ast.stack_name, branch);
        println!("    URL: {}", url.cyan());
        println!();
    }

    if let Some(branch_name) = branch {
        println!("  Branch: {}", branch_name.cyan());
    }

    println!();
    println!("{}", "Run without --dry-run to deploy.".dimmed());

    Ok(())
}

fn get_expected_url(client: &Option<ApiClient>, stack_name: &str, branch: Option<&str>) -> String {
    let existing_slug = client
        .as_ref()
        .and_then(|c| c.get_spec_by_name(stack_name).ok())
        .flatten()
        .map(|spec| spec.url_slug);

    let name_lower = stack_name.to_lowercase();

    match (existing_slug, branch) {
        (Some(slug), Some(b)) => {
            format!(
                "wss://{}-{}-{}.{}",
                name_lower, slug, b, DEFAULT_DOMAIN_SUFFIX
            )
        }
        (Some(slug), None) => {
            format!("wss://{}-{}.{}", name_lower, slug, DEFAULT_DOMAIN_SUFFIX)
        }
        (None, Some(b)) => {
            format!(
                "wss://{}-<slug>-{}.{} (slug assigned on first deploy)",
                name_lower, b, DEFAULT_DOMAIN_SUFFIX
            )
        }
        (None, None) => {
            format!(
                "wss://{}-<slug>.{} (slug assigned on first deploy)",
                name_lower, DEFAULT_DOMAIN_SUFFIX
            )
        }
    }
}

fn deploy_single_stack(
    client: &ApiClient,
    ast: &crate::config::DiscoveredAst,
    branch: Option<&str>,
) -> Result<()> {
    ui::print_divider();
    if let Some(branch_name) = branch {
        println!(
            "{} Deploying {} (branch: {})",
            ui::symbols::ARROW.blue().bold(),
            ast.stack_name.bold(),
            branch_name.cyan()
        );
    } else {
        println!(
            "{} Deploying {}",
            ui::symbols::ARROW.blue().bold(),
            ast.stack_name.bold()
        );
    }
    ui::print_divider();

    ui::print_numbered_step(1, "Pushing stack...");

    let remote_spec = client.get_spec_by_name(&ast.stack_name)?;

    let spec_id = if let Some(spec) = remote_spec {
        println!(
            "  {} Stack exists (id={})",
            ui::symbols::SUCCESS.green(),
            spec.id
        );
        spec.id
    } else {
        let spinner = ui::create_spinner("Creating stack...");
        let req = crate::api_client::CreateSpecRequest {
            name: ast.stack_name.clone(),
            entity_name: ast.stack_id.clone(),
            crate_name: String::new(),
            module_path: String::new(),
            description: None,
            package_name: None,
            output_path: None,
        };
        let new_spec = client.create_spec(req)?;
        spinner.finish_with_message(format!("{} Stack created", ui::symbols::SUCCESS.green()));
        new_spec.id
    };

    let spinner = ui::create_spinner("Uploading stack...");
    let ast_payload = ast.load_ast()?;
    let version_response = client.create_spec_version(spec_id, ast_payload)?;

    let hash_short = &version_response.version.content_hash[..12];
    if version_response.version_is_new {
        spinner.finish_with_message(format!(
            "{} v{} ({})",
            ui::symbols::SUCCESS.green(),
            version_response.version.version_number,
            hash_short
        ));
    } else {
        spinner.finish_with_message(format!(
            "{} v{} (up to date)",
            ui::symbols::EQUALS.blue(),
            version_response.version.version_number
        ));
    }

    ui::print_numbered_step(2, "Creating build...");

    let req = CreateBuildRequest {
        spec_id: Some(spec_id),
        spec_version_id: Some(version_response.version.id),
        ast_payload: None,
        branch: branch.map(|s| s.to_string()),
    };

    let build_response = client.create_build(req)?;
    println!("  Build ID: {}", build_response.build_id.to_string().bold());
    if let Some(branch_name) = branch {
        println!("  Branch: {}", branch_name.cyan());
    }

    ui::print_numbered_step(3, "Building & deploying...");
    println!();

    watch_build_progress(client, build_response.build_id)?;

    Ok(())
}

fn watch_build_progress(client: &ApiClient, build_id: i32) -> Result<()> {
    let mut last_phase: Option<String> = None;
    let progress_bar = ProgressBar::new(100);
    progress_bar.set_style(
        ProgressStyle::default_bar()
            .template("  {spinner:.blue} [{bar:30.green/dim}] {pos}% {msg}")
            .expect("Invalid progress bar template")
            .progress_chars("█░░"),
    );
    progress_bar.enable_steady_tick(Duration::from_millis(80));

    let start_time = std::time::Instant::now();
    let timeout = Duration::from_secs(ui::DEFAULT_POLL_TIMEOUT_SECS);

    loop {
        if start_time.elapsed() > timeout {
            progress_bar.finish_and_clear();
            anyhow::bail!(
                "Build timed out after {} minutes. Check build status with: hs build status {}",
                timeout.as_secs() / 60,
                build_id
            );
        }

        let response = client.get_build(build_id)?;
        let build = &response.build;

        if last_phase != build.phase {
            if let Some(phase) = &build.phase {
                let phase_display = ui::humanize_phase(phase);
                progress_bar.set_message(phase_display.to_string());
            }
            last_phase = build.phase.clone();
        }

        if let Some(progress) = build.progress {
            progress_bar.set_position(progress as u64);
        }

        if build.status.is_terminal() {
            progress_bar.finish_and_clear();
            println!();

            match build.status {
                BuildStatus::Completed => {
                    ui::print_success("Deployed successfully!");

                    if let Some(ws_url) = &build.websocket_url {
                        println!();
                        println!("  {} {}", "WebSocket:".bold(), ws_url.cyan().bold());
                    }
                }
                BuildStatus::Failed => {
                    ui::print_error("Build failed!");

                    if let Some(msg) = &build.status_message {
                        println!("  {}", msg);
                    }

                    anyhow::bail!("Deployment failed");
                }
                BuildStatus::Cancelled => {
                    ui::print_warning("Build was cancelled.");
                    anyhow::bail!("Deployment cancelled");
                }
                _ => {}
            }

            break;
        }

        std::thread::sleep(Duration::from_millis(ui::DEFAULT_POLL_INTERVAL_MS));
    }

    Ok(())
}