xbp 10.30.1

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
mod deploy;
mod project;
mod worktree;
mod wrangler;

use crate::cli::commands::{
    WorkersCmd, WorkersDeploySubCommand, WorkersSecretsBulkCmd, WorkersSecretsPutCmd,
    WorkersSecretsSubCommand, WorkersSubCommand, WorkersTargetArgs, WorkersWorktreeSubCommand,
    WorkersWranglerSubCommand,
};
use crate::config::{resolve_cloudflare_account_id, resolve_cloudflare_api_token};
use crate::provider_support::CloudflareClient;
use serde_json::{json, Map, Value};
use std::fs;
use std::io::{self, Read};
use std::path::Path;

pub async fn run_workers(cmd: WorkersCmd, _debug: bool) -> Result<(), String> {
    let worker_root = project::resolve_workers_project_root(cmd.root.as_deref())?;

    match cmd.command {
        WorkersSubCommand::Secrets(secrets_cmd) => {
            run_worker_secrets(
                &worker_root,
                cmd.token.as_deref(),
                cmd.account_id.as_deref(),
                secrets_cmd.target,
                secrets_cmd.command,
            )
            .await
        }
        WorkersSubCommand::Settings(settings_cmd) => {
            let local_env = load_local_env(&worker_root)?;
            let script_name =
                resolve_target_script_name(&worker_root, &settings_cmd.target, &local_env);
            let client = build_cloudflare_client(
                cmd.token.as_deref(),
                cmd.account_id.as_deref(),
                &local_env,
            )?;
            let settings = client.get_worker_settings(&script_name).await?;
            match settings {
                Some(settings) => print_json(&json!({
                    "script_name": script_name,
                    "settings": settings,
                })),
                None => Err(format!(
                    "Worker settings were not found for script `{}`.",
                    script_name
                )),
            }
        }
        WorkersSubCommand::Wrangler(wrangler_cmd) => match wrangler_cmd.command {
            WorkersWranglerSubCommand::GenerateConfig(generate_cmd) => {
                wrangler::run_generate_config(&worker_root, &generate_cmd.output)
            }
            WorkersWranglerSubCommand::ConfigPath(config_path_cmd) => {
                match project::resolve_wrangler_config_path(
                    &worker_root,
                    &config_path_cmd.command_name,
                    &config_path_cmd.mode,
                ) {
                    Some(path) => {
                        println!("{}", path);
                        Ok(())
                    }
                    None => {
                        println!();
                        Ok(())
                    }
                }
            }
        },
        WorkersSubCommand::D1(d1_cmd) => match d1_cmd.command {
            crate::cli::commands::WorkersD1SubCommand::Migrations(migrations_cmd) => {
                match migrations_cmd.command {
                    crate::cli::commands::WorkersD1MigrationsSubCommand::Apply(apply_cmd) => {
                        wrangler::run_d1_migrations_apply(&worker_root, &apply_cmd)
                    }
                }
            }
        },
        WorkersSubCommand::Deploy(deploy_cmd) => match deploy_cmd.command {
            WorkersDeploySubCommand::Predeploy(predeploy_cmd) => {
                deploy::run_predeploy(
                    &worker_root,
                    cmd.token.as_deref(),
                    cmd.account_id.as_deref(),
                    predeploy_cmd.ci,
                )
                .await
            }
            WorkersDeploySubCommand::SyncEnvLocal(_) => {
                deploy::run_sync_env_local(
                    &worker_root,
                    cmd.token.as_deref(),
                    cmd.account_id.as_deref(),
                )
                .await
            }
            WorkersDeploySubCommand::Ci(ci_cmd) => {
                deploy::run_deploy_ci_script(&worker_root, ci_cmd.version_upload)
            }
            WorkersDeploySubCommand::Select(select_cmd) => deploy::run_select_deploy_script(
                &worker_root,
                select_cmd.ci,
                select_cmd.branch.as_deref(),
            ),
        },
        WorkersSubCommand::Worktree(worktree_cmd) => match worktree_cmd.command {
            WorkersWorktreeSubCommand::Paths(_) => worktree::print_worktree_paths(&worker_root),
            WorkersWorktreeSubCommand::LinkDevVars(_) => {
                worktree::link_dev_vars_from_primary_worktree(&worker_root)
            }
        },
        WorkersSubCommand::Env(env_cmd) => {
            let local_env = load_local_env(&worker_root)?;
            let script_name = resolve_target_script_name(&worker_root, &env_cmd.target, &local_env);
            let summary = wrangler::build_env_summary(
                &worker_root,
                &script_name,
                &local_env,
                env_cmd.show_values,
            )?;
            print_json(&summary)
        }
    }
}

async fn run_worker_secrets(
    worker_root: &Path,
    token_override: Option<&str>,
    account_id_override: Option<&str>,
    target: WorkersTargetArgs,
    command: WorkersSecretsSubCommand,
) -> Result<(), String> {
    let local_env = load_local_env(worker_root)?;
    let script_name = resolve_target_script_name(worker_root, &target, &local_env);
    let client = build_cloudflare_client(token_override, account_id_override, &local_env)?;

    match command {
        WorkersSecretsSubCommand::List(_) => {
            let secrets = client.list_worker_secrets(&script_name).await?;
            print_json(&json!({
                "script_name": script_name,
                "secrets": secrets,
            }))
        }
        WorkersSecretsSubCommand::Get(get_cmd) => {
            let secret = client
                .get_worker_secret(&script_name, &get_cmd.name)
                .await?;
            print_json(&json!({
                "script_name": script_name,
                "secret": secret,
            }))
        }
        WorkersSecretsSubCommand::Put(put_cmd) => {
            let secret_value = resolve_secret_value(&put_cmd)?;
            let result = client
                .put_worker_secret(&script_name, &put_cmd.name, &secret_value)
                .await?;
            print_json(&json!({
                "script_name": script_name,
                "secret": result,
            }))
        }
        WorkersSecretsSubCommand::Delete(delete_cmd) => {
            client
                .delete_worker_secret(&script_name, &delete_cmd.name)
                .await?;
            println!(
                "Deleted Worker secret `{}` from script `{}`.",
                delete_cmd.name, script_name
            );
            Ok(())
        }
        WorkersSecretsSubCommand::Bulk(bulk_cmd) => {
            let patch = read_bulk_secret_patch(&bulk_cmd)?;
            let result = client
                .patch_worker_secrets_bulk(&script_name, &patch)
                .await?;
            print_json(&json!({
                "script_name": script_name,
                "result": result,
            }))
        }
    }
}

fn resolve_target_script_name(
    worker_root: &Path,
    target: &WorkersTargetArgs,
    local_env: &std::collections::HashMap<String, String>,
) -> String {
    project::resolve_remote_script_name(
        worker_root,
        target.script.as_deref(),
        target.worker.as_deref(),
        target.environment.as_deref(),
        local_env,
    )
}

fn build_cloudflare_client(
    token_override: Option<&str>,
    account_id_override: Option<&str>,
    local_env: &std::collections::HashMap<String, String>,
) -> Result<CloudflareClient, String> {
    let token = token_override
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .or_else(|| local_env.get("CLOUDFLARE_API_TOKEN").cloned())
        .or_else(resolve_cloudflare_api_token)
        .ok_or_else(|| {
            "No Cloudflare API token found. Use `--token`, `CLOUDFLARE_API_TOKEN`, or `xbp config cloudflare set-key`.".to_string()
        })?;
    let account_id = account_id_override
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .or_else(|| local_env.get("CLOUDFLARE_ACCOUNT_ID").cloned())
        .or_else(resolve_cloudflare_account_id)
        .ok_or_else(|| {
            "No Cloudflare account ID found. Use `--account-id`, `CLOUDFLARE_ACCOUNT_ID`, or `xbp config cloudflare set-account-id`.".to_string()
        })?;
    CloudflareClient::new(token, account_id)
}

fn load_local_env(worker_root: &Path) -> Result<std::collections::HashMap<String, String>, String> {
    let env_local_path = worker_root.join(".env.local");
    if env_local_path.exists() {
        project::parse_multiline_env_file(&env_local_path)
    } else {
        Ok(std::collections::HashMap::new())
    }
}

fn resolve_secret_value(cmd: &WorkersSecretsPutCmd) -> Result<String, String> {
    match (cmd.from_stdin, cmd.value.as_deref()) {
        (true, Some(_)) => Err("Use either `--value` or `--from-stdin`, not both.".to_string()),
        (true, None) => {
            let mut buffer = String::new();
            io::stdin()
                .read_to_string(&mut buffer)
                .map_err(|error| format!("Failed to read stdin: {}", error))?;
            let value = buffer.trim_end_matches(['\r', '\n']).to_string();
            if value.is_empty() {
                return Err("No secret value was provided on stdin.".to_string());
            }
            Ok(value)
        }
        (false, Some(value)) if !value.trim().is_empty() => Ok(value.to_string()),
        _ => Err("Provide `--value <secret>` or `--from-stdin`.".to_string()),
    }
}

fn read_bulk_secret_patch(cmd: &WorkersSecretsBulkCmd) -> Result<Value, String> {
    let content = fs::read_to_string(&cmd.file)
        .map_err(|error| format!("Failed to read {}: {}", cmd.file.display(), error))?;

    if cmd.format.eq_ignore_ascii_case("json") {
        let parsed = serde_json::from_str::<Value>(&content)
            .map_err(|error| format!("Failed to parse JSON {}: {}", cmd.file.display(), error))?;
        return normalize_secret_patch_value(parsed);
    }

    if !cmd.format.eq_ignore_ascii_case("env") {
        return Err(format!(
            "Unsupported bulk secret format `{}`. Use `env` or `json`.",
            cmd.format
        ));
    }

    let parsed = project::parse_multiline_env_content(&content);
    let patch = parsed
        .into_iter()
        .map(|(key, value)| {
            (
                key,
                json!({
                    "type": "secret_text",
                    "text": value,
                }),
            )
        })
        .collect::<Map<String, Value>>();
    Ok(Value::Object(patch))
}

fn normalize_secret_patch_value(value: Value) -> Result<Value, String> {
    let Value::Object(entries) = value else {
        return Err("Bulk JSON secrets input must be an object.".to_string());
    };

    let mut normalized = Map::new();
    for (key, value) in entries {
        let normalized_value = match value {
            Value::Null => Value::Null,
            Value::String(text) => json!({
                "type": "secret_text",
                "text": text,
            }),
            Value::Object(mut object) => {
                object
                    .entry("type".to_string())
                    .or_insert_with(|| Value::String("secret_text".to_string()));
                Value::Object(object)
            }
            other => {
                return Err(format!(
                    "Invalid bulk secret value for `{}`. Use a string, object, or null, not {}.",
                    key, other
                ));
            }
        };
        normalized.insert(key, normalized_value);
    }
    Ok(Value::Object(normalized))
}

fn print_json(value: &impl serde::Serialize) -> Result<(), String> {
    println!(
        "{}",
        serde_json::to_string_pretty(value)
            .map_err(|error| format!("Failed to encode JSON output: {}", error))?
    );
    Ok(())
}