webby-deploy 0.3.0

Drop a static HTML app into a local, tailnet, temporary public, or durable public URL.
use std::env;
use std::path::Path;
use std::process::{Command, Stdio};

use crate::app::generate_index;
use crate::config::{Bag, Host};
use crate::{Result, err};

pub fn base_url(bag: &Bag, port_override: Option<u16>) -> String {
    match &bag.host {
        Host::Local { url: Some(url), .. }
        | Host::Caddy { url: Some(url) }
        | Host::Command { url: Some(url), .. }
        | Host::CloudflarePages { url: Some(url), .. } => url.trim_end_matches('/').to_string(),
        Host::TailscaleServe {
            url: Some(url),
            path,
            ..
        }
        | Host::TailscaleFunnel {
            url: Some(url),
            path,
            ..
        } => with_path(url, path.as_deref()),
        Host::Local { port, .. } => format!(
            "http://localhost:{}",
            port_override.or(*port).unwrap_or(8765)
        ),
        Host::TailscaleServe { path, .. } | Host::TailscaleFunnel { path, .. } => {
            tailscale_hostname()
                .map(|name| with_path(&format!("https://{name}"), path.as_deref()))
                .unwrap_or_else(|| "(url unknown until tailscale is configured)".to_string())
        }
        Host::CloudflarePages { project, .. } => {
            format!(
                "https://{}.pages.dev",
                project.as_deref().unwrap_or("webby")
            )
        }
        Host::Caddy { .. } | Host::Command { .. } => {
            "(url unknown until host is configured)".to_string()
        }
    }
}

pub fn deploy_bag(bag: &Bag, port_override: Option<u16>) -> Result<()> {
    let apps = generate_index(bag)?;
    match &bag.host {
        Host::Local { .. } => {
            println!("✓ ready: {} app(s) in {}", apps.len(), bag.dir.display());
            println!("  serve: webby serve -b {}", bag.label);
            println!("  url: {}", base_url(bag, port_override));
            Ok(())
        }
        Host::Caddy { .. } => {
            if bag.no_index {
                println!("✓ generated: {}/webby-cards.json", bag.dir.display());
            } else {
                println!("✓ generated: {}/index.html", bag.dir.display());
            }
            println!("✓ live: {}", base_url(bag, None));
            Ok(())
        }
        Host::TailscaleServe {
            path, background, ..
        } => run_tailscale(
            "serve",
            &bag.dir,
            path.as_deref(),
            background.unwrap_or(true),
            bag,
        ),
        Host::TailscaleFunnel {
            path, background, ..
        } => run_tailscale(
            "funnel",
            &bag.dir,
            path.as_deref(),
            background.unwrap_or(true),
            bag,
        ),
        Host::CloudflarePages {
            project,
            account_id,
            token_env,
            token_ref,
            token_command,
            branch,
            ..
        } => run_cloudflare_pages(
            bag,
            project.as_deref().unwrap_or("webby"),
            account_id.as_deref(),
            token_env.as_deref().unwrap_or("CLOUDFLARE_API_TOKEN"),
            token_ref.as_deref(),
            token_command.as_deref(),
            branch.as_deref().unwrap_or("main"),
            apps.len(),
        ),
        Host::Command { deploy, .. } => {
            let Some(template) = deploy else {
                return Err(err(format!("bag '{}' has no deploy command", bag.label)));
            };
            run_command_template(template, bag)?;
            println!("✓ live: {}", base_url(bag, None));
            Ok(())
        }
    }
}

pub fn after_add(bag: &Bag) -> Result<()> {
    if let Host::Command {
        after_add: Some(template),
        ..
    } = &bag.host
    {
        run_command_template(template, bag)?;
    }
    Ok(())
}

pub fn attach_domain(bag: &Bag, hostname: &str) -> Result<()> {
    let Host::CloudflarePages {
        project,
        account_id,
        token_env,
        token_ref,
        token_command,
        ..
    } = &bag.host
    else {
        return Err(err(format!(
            "bag '{}' is not a Cloudflare Pages bag",
            bag.label
        )));
    };
    let project = project.as_deref().unwrap_or("webby");
    let account_id = account_id
        .clone()
        .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok())
        .or_else(|| env::var("CF_ACCOUNT_ID").ok())
        .ok_or_else(|| {
            err("missing Cloudflare account id. Set CLOUDFLARE_ACCOUNT_ID or accountId.")
        })?;
    let token = read_cloudflare_token(
        token_env.as_deref().unwrap_or("CLOUDFLARE_API_TOKEN"),
        token_ref.as_deref(),
        token_command.as_deref(),
    )?
    .ok_or_else(|| {
        err("missing Cloudflare token. Set CLOUDFLARE_API_TOKEN, tokenRef, or tokenCommand.")
    })?;
    let url = format!(
        "https://api.cloudflare.com/client/v4/accounts/{}/pages/projects/{}/domains",
        account_id, project
    );
    let body = serde_json::json!({ "name": hostname }).to_string();
    let response = ureq::post(&url)
        .set("Authorization", &format!("Bearer {token}"))
        .set("Content-Type", "application/json")
        .send_string(&body);
    match response {
        Ok(resp) if (200..300).contains(&resp.status()) => {}
        Ok(resp) => {
            return Err(err(format!(
                "Cloudflare domain attach failed with HTTP {}",
                resp.status()
            )));
        }
        Err(ureq::Error::Status(code, _)) => {
            return Err(err(format!(
                "Cloudflare domain attach failed with HTTP {code}"
            )));
        }
        Err(error) => return Err(err(format!("Cloudflare domain attach failed: {error}"))),
    }
    println!("✓ attached {hostname} to Pages project '{project}'");
    Ok(())
}

pub fn open_app(url: &str) {
    let _ = Command::new("xdg-open")
        .arg(url)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn();
}

fn run_tailscale(
    mode: &str,
    dir: &Path,
    path: Option<&str>,
    background: bool,
    bag: &Bag,
) -> Result<()> {
    let mut args = vec![mode.to_string()];
    if background {
        args.push("--bg".to_string());
    }
    if let Some(path) = path.filter(|p| *p != "/") {
        args.push("--set-path".to_string());
        args.push(path.to_string());
    }
    args.push(dir.to_string_lossy().to_string());
    let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
    run_command("tailscale", &refs, &[])?;
    println!("✓ live: {}", base_url(bag, None));
    Ok(())
}

fn run_cloudflare_pages(
    bag: &Bag,
    project: &str,
    account_id: Option<&str>,
    token_env: &str,
    token_ref: Option<&str>,
    token_command: Option<&str>,
    branch: &str,
    app_count: usize,
) -> Result<()> {
    let mut envs = Vec::new();
    let account_id = account_id
        .map(str::to_string)
        .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok());
    if let Some(account_id) = account_id {
        envs.push(("CLOUDFLARE_ACCOUNT_ID".to_string(), account_id.to_string()));
    }
    if let Some(token) = read_cloudflare_token(token_env, token_ref, token_command)? {
        envs.push(("CLOUDFLARE_API_TOKEN".to_string(), token));
    }

    println!("  deploying {app_count} app(s) to Pages project '{project}'...");
    let dir = bag.dir.to_string_lossy().to_string();
    run_wrangler(
        &[
            "pages",
            "deploy",
            &dir,
            "--project-name",
            project,
            "--branch",
            branch,
            "--commit-dirty=true",
        ],
        &envs,
    )?;
    println!("✓ live: {}", base_url(bag, None));
    Ok(())
}

fn read_cloudflare_token(
    token_env: &str,
    token_ref: Option<&str>,
    token_command: Option<&str>,
) -> Result<Option<String>> {
    if let Ok(token) = env::var(token_env) {
        return Ok(Some(token));
    }
    if let Some(token_ref) = token_ref {
        let output = Command::new("op").args(["read", token_ref]).output()?;
        if !output.status.success() {
            return Err(err(format!("op read failed for {token_ref}")));
        }
        return Ok(Some(
            String::from_utf8_lossy(&output.stdout).trim().to_string(),
        ));
    }
    if let Some(command) = token_command {
        let output = Command::new("sh").args(["-c", command]).output()?;
        if !output.status.success() {
            return Err(err("token command failed"));
        }
        return Ok(Some(
            String::from_utf8_lossy(&output.stdout).trim().to_string(),
        ));
    }
    Ok(None)
}

fn in_path(name: &str) -> bool {
    std::env::var_os("PATH")
        .map(|paths| std::env::split_paths(&paths).any(|p| p.join(name).is_file()))
        .unwrap_or(false)
}

fn run_wrangler(args: &[&str], envs: &[(String, String)]) -> Result<()> {
    if in_path("wrangler") {
        run_command("wrangler", args, envs)
    } else {
        let full: Vec<&str> = ["--yes", "wrangler"]
            .into_iter()
            .chain(args.iter().copied())
            .collect();
        run_command("npx", &full, envs)
    }
}

fn run_command(program: &str, args: &[&str], envs: &[(String, String)]) -> Result<()> {
    let mut command = Command::new(program);
    command.args(args);
    for (key, value) in envs {
        command.env(key, value);
    }
    let status = command
        .status()
        .map_err(|e| err(format!("failed to run {program}: {e}")))?;
    if !status.success() {
        return Err(err(format!("{program} exited with {status}")));
    }
    Ok(())
}

fn run_command_template(template: &str, bag: &Bag) -> Result<()> {
    let command = template
        .replace("{dir}", &bag.dir.to_string_lossy())
        .replace("{label}", &bag.label)
        .replace("{url}", &base_url(bag, None));
    let status = Command::new("sh").args(["-c", &command]).status()?;
    if !status.success() {
        return Err(err(format!("command failed: {command}")));
    }
    Ok(())
}

fn tailscale_hostname() -> Option<String> {
    let output = Command::new("tailscale")
        .args(["status", "--json"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
    value
        .pointer("/Self/DNSName")
        .and_then(|v| v.as_str())
        .map(|dns| dns.trim_end_matches('.').to_string())
}

fn with_path(base: &str, path: Option<&str>) -> String {
    let base = base.trim_end_matches('/');
    match path {
        Some("/") | None => base.to_string(),
        Some(path) => format!("{}/{}", base, path.trim_matches('/')),
    }
}