use super::project::{
discover_worker_configs_on_disk, parse_multiline_env_file, pick_first_non_empty,
read_configured_custom_domain_routes, upsert_worker_configs, write_worker_configs_to_project,
DiscoveredWorkerApp, WorkerTargetResolution,
};
use super::wrangler::{
apply_remote_settings_to_config, build_worker_plain_text_bindings,
generate_dashboard_config_value, resolve_deploy_env, run_wrangler, write_json_file,
WORKER_PLAIN_TEXT_BINDING_KEYS,
};
use crate::provider_support::CloudflareClient;
use crate::strategies::WorkerConfig;
use crate::utils::node_toolchain_wrapper_path;
use colored::Colorize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub async fn run_predeploy(
worker_root: &Path,
token_override: Option<&str>,
account_id_override: Option<&str>,
ci: bool,
) -> Result<(), String> {
if ci || env::var("WORKERS_CI").ok().as_deref() == Some("1") {
println!("Skipping local predeploy sync because Workers CI mode is active.");
return Ok(());
}
if supports_dashboard_sync(worker_root) {
return run_sync_env_local(worker_root, token_override, account_id_override).await;
}
if worker_root.join("scripts/predeploy.mjs").exists()
|| read_package_script(worker_root, "predeploy").is_some()
{
println!(
"Running package `predeploy` for {}…",
worker_root.display()
);
return run_package_script(worker_root, "predeploy");
}
println!(
"No dashboard sync or package predeploy for {} — nothing to do.",
worker_root.display()
);
Ok(())
}
pub async fn run_sync_env_local(
worker_root: &Path,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
let env_local_path = worker_root.join(".env.local");
let has_env_local = env_local_path.exists();
let mut local_env = if has_env_local {
parse_multiline_env_file(&env_local_path)?
} else {
HashMap::new()
};
if let Some(account_id) = account_id_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
local_env.insert("CLOUDFLARE_ACCOUNT_ID".to_string(), account_id.to_string());
}
if let Some(token) = token_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
local_env.insert("CLOUDFLARE_API_TOKEN".to_string(), token.to_string());
}
let deploy_env = resolve_deploy_env(worker_root, &local_env)?;
let worker_vars = build_worker_plain_text_bindings(&local_env, &deploy_env.worker_name)?;
let account_id = deploy_env.account_id.clone().ok_or_else(|| {
"Missing required deploy value: CLOUDFLARE_ACCOUNT_ID. Set it in .env.local or the environment.".to_string()
})?;
write_dev_vars_file(worker_root, &worker_vars)?;
let mut deploy_config = generate_dashboard_config_value(&deploy_env);
deploy_config["account_id"] = Value::String(account_id.clone());
deploy_config["routes"] = Value::Array(read_configured_custom_domain_routes(worker_root));
deploy_config["vars"] = Value::Object(
worker_vars
.iter()
.map(|(key, value)| (key.clone(), Value::String(value.clone())))
.collect::<Map<String, Value>>(),
);
let remote_settings = match fetch_remote_worker_settings(
token_override,
&local_env,
&account_id,
&deploy_env.worker_name,
)
.await
{
Ok(settings) => settings,
Err(err) => {
eprintln!(
"Warning: could not fetch remote Worker settings for `{}` ({}); continuing with local config.",
deploy_env.worker_name, err
);
None
}
};
let fallback_routes = deploy_config
.get("routes")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
apply_remote_settings_to_config(
&mut deploy_config,
remote_settings.as_ref(),
&fallback_routes,
&worker_vars,
);
write_json_file(&worker_root.join("wrangler.deploy.json"), &deploy_config)?;
write_json_file(&worker_root.join("wrangler.jsonc"), &deploy_config)?;
write_json_file(&worker_root.join("wrangler.dev.jsonc"), &deploy_config)?;
println!("Prepared:");
println!(
" - deploy inputs source={}",
if has_env_local {
".env.local + environment"
} else {
"environment"
}
);
println!(" - .dev.vars");
println!(" - wrangler.deploy.json");
println!(" - wrangler.jsonc");
println!(" - wrangler.dev.jsonc");
if let Some(url) = worker_vars.get("BETTER_AUTH_URL") {
println!(" - BETTER_AUTH_URL={}", url);
}
Ok(())
}
pub async fn run_deploy_ci_apps(
apps: &[DiscoveredWorkerApp],
version_upload: bool,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
for app in apps {
println!("Deploying {} at {}", app.label.bold(), app.root.display());
run_native_deploy_ci(
&app.root,
app.config.as_ref(),
version_upload,
token_override,
account_id_override,
)
.await?;
}
Ok(())
}
pub async fn run_native_deploy_ci(
worker_root: &Path,
worker_config: Option<&WorkerConfig>,
version_upload: bool,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
if should_use_legacy_dashboard_deploy_ci(worker_root, worker_config) {
return run_legacy_dashboard_deploy_ci(worker_root, version_upload);
}
println!("Using built-in XBP Worker CI deploy.");
if supports_dashboard_sync(worker_root) {
run_sync_env_local(worker_root, token_override, account_id_override).await?;
}
run_optional_ci_build(worker_root)?;
let built_config = worker_root
.join("dist")
.join("server")
.join("wrangler.json");
let deploy_config = resolve_wrangler_config_for_deploy(worker_root);
let wrangler_config = if built_config.exists() {
built_config
} else {
deploy_config
};
let mut wrangler_args = wrangler_config_args(&wrangler_config, worker_root);
if version_upload {
wrangler_args.insert(0, "versions".to_string());
wrangler_args.insert(1, "upload".to_string());
} else {
wrangler_args.insert(0, "deploy".to_string());
}
apply_process_env_from_local(worker_root, token_override, account_id_override)?;
run_wrangler(worker_root, &wrangler_args)
}
fn run_legacy_dashboard_deploy_ci(worker_root: &Path, version_upload: bool) -> Result<(), String> {
println!("Using legacy dashboard deploy-ci script.");
let mut args = vec!["scripts/deploy-ci.mjs".to_string()];
if version_upload {
args.push("--version-upload".to_string());
}
run_node_script(worker_root, &args, &HashMap::new())
}
fn should_use_legacy_dashboard_deploy_ci(
worker_root: &Path,
worker_config: Option<&WorkerConfig>,
) -> bool {
if let Some(script) = worker_config
.and_then(|config| config.deploy.as_ref())
.and_then(|deploy| deploy.ci_script.as_deref())
{
return worker_root.join(script).exists();
}
worker_root.join("scripts/deploy-ci.mjs").exists() && supports_dashboard_sync(worker_root)
}
pub async fn run_configure_workers(
resolution: &WorkerTargetResolution,
write_config: bool,
dry_run: bool,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
if write_config {
let discovered = discover_worker_configs_on_disk(&resolution.project_root);
if discovered.is_empty() {
return Err(format!(
"No Worker projects were discovered under {}.",
resolution.project_root.display()
));
}
let mut config = resolution.config.clone();
let inserted = upsert_worker_configs(&mut config, discovered);
if dry_run {
println!(
"Would update {} with {} new worker entr{}:",
resolution.config_path.display(),
inserted,
if inserted == 1 { "y" } else { "ies" }
);
for worker in get_all_configured_workers(&config) {
println!(
" - {} -> {} ({})",
worker.name,
worker.root,
worker.script_name.as_deref().unwrap_or("auto")
);
}
return Ok(());
}
write_worker_configs_to_project(
&resolution.project_root,
&resolution.config_path,
&mut config,
)?;
println!(
"Updated {} with {} new worker entr{}.",
resolution.config_path.display(),
inserted,
if inserted == 1 { "y" } else { "ies" }
);
}
for app in &resolution.selected {
println!("Configuring {} at {}", app.label.bold(), app.root.display());
if supports_dashboard_sync(&app.root) {
run_sync_env_local(&app.root, token_override, account_id_override).await?;
} else {
println!(
" Skipping dashboard env sync for {} (no generate-wrangler-dashboard-config.mjs).",
app.label
);
}
}
Ok(())
}
pub fn run_deploy_for_apps(
apps: &[DiscoveredWorkerApp],
mode: DeployExecutionMode,
version_upload: bool,
ci: bool,
branch: Option<&str>,
) -> Result<(), String> {
for app in apps {
println!("Deploying {} at {}", app.label.bold(), app.root.display());
run_deploy_for_app(
&app.root,
app.config.as_ref(),
mode,
version_upload,
ci,
branch,
)?;
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub enum DeployExecutionMode {
Select,
Run,
}
async fn fetch_remote_worker_settings(
token_override: Option<&str>,
local_env: &HashMap<String, String>,
account_id: &str,
worker_name: &str,
) -> Result<Option<crate::provider_support::CloudflareWorkerSettings>, String> {
let api_token = pick_first_non_empty([
token_override.map(str::trim).map(ToOwned::to_owned),
local_env.get("CLOUDFLARE_API_TOKEN").cloned(),
env::var("CLOUDFLARE_API_TOKEN").ok(),
crate::config::resolve_cloudflare_api_token(),
]);
let Some(api_token) = api_token else {
return Ok(None);
};
let client = CloudflareClient::new(api_token, account_id.to_string())?;
client.get_worker_settings(worker_name).await
}
fn write_dev_vars_file(
worker_root: &Path,
worker_vars: &HashMap<String, String>,
) -> Result<(), String> {
let ordered_vars = WORKER_PLAIN_TEXT_BINDING_KEYS
.iter()
.filter_map(|key| {
worker_vars
.get(*key)
.map(|value| format!("{key}={}", quote_dev_var(value)))
})
.collect::<Vec<_>>();
let lines = WORKER_DEV_VAR_HEADER
.iter()
.copied()
.map(str::to_string)
.chain(ordered_vars)
.collect::<Vec<_>>();
std::fs::write(
worker_root.join(".dev.vars"),
format!("{}\n", lines.join("\n")),
)
.map_err(|error| {
format!(
"Failed to write {}: {}",
worker_root.join(".dev.vars").display(),
error
)
})
}
fn run_deploy_for_app(
worker_root: &Path,
worker_config: Option<&WorkerConfig>,
mode: DeployExecutionMode,
_version_upload: bool,
ci: bool,
branch: Option<&str>,
) -> Result<(), String> {
match mode {
DeployExecutionMode::Select => {
let script = resolve_deploy_script(worker_root, worker_config)?;
run_node_script(worker_root, &[script], &deploy_env_overrides(ci, branch))
}
DeployExecutionMode::Run => run_configured_deploy_command(worker_root, worker_config),
}
}
fn run_configured_deploy_command(
worker_root: &Path,
worker_config: Option<&WorkerConfig>,
) -> Result<(), String> {
if let Some(command) = worker_config
.and_then(|config| config.deploy.as_ref())
.and_then(|deploy| deploy.command.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
{
return run_shell_command(worker_root, command);
}
for script_name in ["deploy", "deploy:worker", "deploy:production"] {
if let Some(command) = read_package_script(worker_root, script_name) {
return run_package_script(worker_root, &command);
}
}
if worker_root.join("wrangler.jsonc").exists() || worker_root.join("wrangler.toml").exists() {
return run_shell_command(worker_root, "wrangler deploy");
}
Err(format!(
"No deploy command found for {}. Add `workers[].deploy.command` to .xbp/xbp.yaml or a package.json deploy script.",
worker_root.display()
))
}
fn resolve_deploy_script(
worker_root: &Path,
worker_config: Option<&WorkerConfig>,
) -> Result<String, String> {
let configured = worker_config.and_then(|config| config.deploy.as_ref());
let script = configured
.and_then(|deploy| deploy.select_script.clone())
.unwrap_or_else(|| "scripts/select-deploy-command.mjs".to_string());
if worker_root.join(&script).exists() {
return Ok(script);
}
Err(format!(
"Deploy script `{}` was not found in {}.",
script,
worker_root.display()
))
}
fn resolve_wrangler_config_for_deploy(worker_root: &Path) -> PathBuf {
for candidate in ["wrangler.deploy.json", "wrangler.jsonc", "wrangler.toml"] {
let path = worker_root.join(candidate);
if path.exists() {
return path;
}
}
worker_root.join("wrangler.jsonc")
}
fn wrangler_config_args(config: &Path, worker_root: &Path) -> Vec<String> {
let rendered = if config.is_absolute() {
config.to_path_buf()
} else {
worker_root.join(config)
};
let relative = rendered
.strip_prefix(worker_root)
.unwrap_or(&rendered)
.to_string_lossy()
.replace('\\', "/");
vec!["-c".to_string(), relative]
}
fn run_optional_ci_build(worker_root: &Path) -> Result<(), String> {
for script_name in ["build:worker", "build"] {
let Some(command) = read_package_script(worker_root, script_name) else {
continue;
};
if command.contains("--dry-run") {
println!("Skipping `{script_name}` (dry-run only).");
continue;
}
println!("Running `{script_name}` before deploy.");
run_package_script(worker_root, script_name)?;
return Ok(());
}
Ok(())
}
fn apply_process_env_from_local(
worker_root: &Path,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
use crate::config::{resolve_cloudflare_account_id, resolve_cloudflare_api_token};
let local_env = parse_multiline_env_file(&worker_root.join(".env.local")).unwrap_or_default();
for (key, value) in local_env {
if !value.trim().is_empty() {
env::set_var(&key, value);
}
}
let token = token_override
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(resolve_cloudflare_api_token);
if let Some(token) = token {
let force = token_override
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
if force || env::var_os("CLOUDFLARE_API_TOKEN").is_none() {
env::set_var("CLOUDFLARE_API_TOKEN", token);
}
}
let account_id = account_id_override
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(resolve_cloudflare_account_id);
if let Some(account_id) = account_id {
let force = account_id_override
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
if force || env::var_os("CLOUDFLARE_ACCOUNT_ID").is_none() {
env::set_var("CLOUDFLARE_ACCOUNT_ID", account_id);
}
}
Ok(())
}
fn deploy_env_overrides(ci: bool, branch: Option<&str>) -> HashMap<String, String> {
let mut env_overrides = HashMap::new();
if ci {
env_overrides.insert("WORKERS_CI".to_string(), "1".to_string());
}
if let Some(branch) = branch.map(str::trim).filter(|value| !value.is_empty()) {
env_overrides.insert("WORKERS_CI_BRANCH".to_string(), branch.to_string());
}
env_overrides
}
fn supports_dashboard_sync(worker_root: &Path) -> bool {
worker_root
.join("scripts")
.join("generate-wrangler-dashboard-config.mjs")
.exists()
}
fn get_all_configured_workers(config: &crate::strategies::XbpConfig) -> Vec<WorkerConfig> {
crate::strategies::get_all_workers(config)
}
fn read_package_script(worker_root: &Path, script_name: &str) -> Option<String> {
let package_json = fs::read_to_string(worker_root.join("package.json")).ok()?;
let value: serde_json::Value = serde_json::from_str(&package_json).ok()?;
value
.get("scripts")
.and_then(|scripts| scripts.get(script_name))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
fn run_package_script(worker_root: &Path, script_name: &str) -> Result<(), String> {
apply_process_env_from_local(worker_root, None, None)?;
if let Some(command) = read_package_script(worker_root, script_name) {
if command.starts_with("node ") {
let args = command
.split_whitespace()
.skip(1)
.map(str::to_string)
.collect::<Vec<_>>();
return run_node_script(worker_root, &args, &HashMap::new());
}
}
if which_package_manager(worker_root) == "npm" {
run_shell_command(worker_root, &format!("npm run {script_name}"))
} else {
run_shell_command(worker_root, &format!("pnpm run {script_name}"))
}
}
fn which_package_manager(worker_root: &Path) -> &'static str {
if worker_root.join("pnpm-lock.yaml").exists()
|| worker_root.join("pnpm-workspace.yaml").exists()
{
"pnpm"
} else {
"npm"
}
}
fn run_shell_command(worker_root: &Path, command: &str) -> Result<(), String> {
let shell = if cfg!(windows) { "cmd" } else { "sh" };
let arg = if cfg!(windows) {
format!("/C {command}")
} else {
format!("-c {command}")
};
let status = Command::new(shell)
.arg(arg)
.current_dir(worker_root)
.status()
.map_err(|error| {
format!(
"Failed to run `{command}` in {}: {}",
worker_root.display(),
error
)
})?;
if !status.success() {
return Err(format!(
"Command `{command}` failed in {} with status {}.",
worker_root.display(),
status
));
}
Ok(())
}
fn run_node_script(
worker_root: &Path,
script_args: &[String],
env_overrides: &HashMap<String, String>,
) -> Result<(), String> {
let mut command = if let Some(wrapper_path) = node_toolchain_wrapper_path(worker_root) {
let mut command = Command::new("node");
command.arg(wrapper_path).arg("node").args(script_args);
command
} else {
let mut command = Command::new("node");
command.args(script_args);
command
};
command.current_dir(worker_root);
for (key, value) in env_overrides {
command.env(key, value);
}
let status = command
.status()
.map_err(|error| format!("Failed to run node {}: {}", script_args.join(" "), error))?;
if !status.success() {
return Err(format!(
"Node script `{}` failed with status {}.",
script_args.join(" "),
status
));
}
Ok(())
}
fn quote_dev_var(value: &str) -> String {
format!(
"\"{}\"",
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
)
}
const WORKER_DEV_VAR_HEADER: &[&str] = &["# Generated from .env.local - do not commit"];