use super::project::{
pick_first_non_empty, read_configured_custom_domain_routes, resolve_dashboard_worker_name,
resolve_wrangler_config_path,
};
use super::worktree::{get_shared_wrangler_state_path, is_worktree_checkout};
use crate::cli::commands::WorkersD1MigrationsApplyCmd;
use crate::provider_support::CloudflareWorkerSettings;
use crate::strategies::{get_all_workers, XbpConfig};
use crate::utils::{find_xbp_config_upwards, parse_config_with_auto_heal};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};
use uuid::Uuid;
pub const WORKER_PLAIN_TEXT_BINDING_KEYS: &[&str] = &[
"GITHUB_OAUTH_CLIENT_ID",
"GITHUB_OAUTH_CLIENT_SECRET",
"GITHUB_APP_CLIENT_ID",
"GITHUB_APP_CLIENT_SECRET",
"GITHUB_APP_ID",
"GITHUB_APP_PRIVATE_KEY",
"GITHUB_APP_SLUG",
"GITHUB_WEBHOOK_SECRET",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"R2_PUBLIC_BASE_URL",
"DEV_TUNNEL_URL",
"XBP_CONTROL_PLANE_API_URL",
"XBP_CONTROL_PLANE_UPSTREAM_URL",
"XBP_API_SHARED_TOKEN",
];
#[derive(Debug, Clone)]
pub struct ResolvedDeployEnv {
pub compatibility_date: String,
pub worker_name: String,
pub d1_name: String,
pub d1_id: String,
pub kv_id: String,
pub kv_preview_id: String,
pub r2_bucket: String,
pub r2_preview_bucket: String,
pub account_id: Option<String>,
}
pub fn run_generate_config(worker_root: &Path, output: &Path) -> Result<(), String> {
let deploy_env = resolve_deploy_env(worker_root, &HashMap::new())?;
let output_path = if output.is_absolute() {
output.to_path_buf()
} else {
worker_root.join(output)
};
let mut config = generate_dashboard_config_value(&deploy_env);
apply_xbp_worker_resources(worker_root, &mut config)?;
write_json_file(&output_path, &config)?;
println!("Wrote {}", output_path.display());
Ok(())
}
pub fn apply_xbp_worker_resources(worker_root: &Path, config: &mut Value) -> Result<(), String> {
let Some(found) = find_xbp_config_upwards(worker_root) else {
return Ok(());
};
let content = fs::read_to_string(&found.config_path)
.map_err(|error| format!("Failed to read {}: {}", found.config_path.display(), error))?;
let (xbp, _) = parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)?;
let selected = get_all_workers(&xbp).into_iter().find(|worker| {
let configured = Path::new(&worker.root);
let absolute = if configured.is_absolute() {
configured.to_path_buf()
} else {
found.project_root.join(configured)
};
same_path(&absolute, worker_root)
});
let Some(worker) = selected else {
return Ok(());
};
let Some(durable_objects) = worker.durable_objects.as_ref() else {
return Ok(());
};
let bindings = serde_json::to_value(&durable_objects.bindings)
.map_err(|error| format!("Failed to encode Durable Object bindings: {error}"))?;
config["durable_objects"] = json!({ "bindings": bindings });
config["migrations"] = serde_json::to_value(&durable_objects.migrations)
.map_err(|error| format!("Failed to encode Durable Object migrations: {error}"))?;
let mut containers = worker.containers.clone().unwrap_or_default();
if containers.is_empty() {
if let Some(container) = worker.container.clone() {
containers.push(container);
}
}
if !containers.is_empty() {
config["containers"] = serde_json::to_value(containers)
.map_err(|error| format!("Failed to encode Worker Containers: {error}"))?;
}
Ok(())
}
fn same_path(left: &Path, right: &Path) -> bool {
let normalize = |path: &Path| {
path.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase()
};
normalize(left) == normalize(right)
}
pub fn resolve_deploy_env(
worker_root: &Path,
local_env: &HashMap<String, String>,
) -> Result<ResolvedDeployEnv, String> {
let compatibility_date = resolve_var(local_env, &["WRANGLER_COMPATIBILITY_DATE"])
.unwrap_or_else(|| "2025-09-02".to_string());
let worker_name = resolve_dashboard_worker_name(worker_root, local_env);
let d1_name = resolve_var(local_env, &["DASHBOARD_PROD_D1_DATABASE_NAME"])
.unwrap_or_else(|| "xbp-db".to_string());
let d1_id = require_var(local_env, "DASHBOARD_PROD_D1_DATABASE_ID")?;
let kv_id = require_var(local_env, "DASHBOARD_PROD_KV_ID")?;
let kv_preview_id = pick_first_non_empty([
resolve_var(local_env, &["DASHBOARD_PROD_KV_PREVIEW_ID"]),
Some(kv_id.clone()),
])
.unwrap_or_else(|| kv_id.clone());
let r2_bucket = require_var(local_env, "DASHBOARD_PROD_R2_BUCKET")?;
let r2_preview_bucket = pick_first_non_empty([
resolve_var(local_env, &["DASHBOARD_PROD_R2_PREVIEW_BUCKET"]),
Some(r2_bucket.clone()),
])
.unwrap_or_else(|| r2_bucket.clone());
let account_id = resolve_var(local_env, &["CLOUDFLARE_ACCOUNT_ID"]);
Ok(ResolvedDeployEnv {
compatibility_date,
worker_name,
d1_name,
d1_id,
kv_id,
kv_preview_id,
r2_bucket,
r2_preview_bucket,
account_id,
})
}
pub fn build_worker_plain_text_bindings(
local_env: &HashMap<String, String>,
fallback_worker_name: &str,
) -> Result<HashMap<String, String>, String> {
let mut vars = HashMap::new();
insert_if_present(
&mut vars,
"GITHUB_OAUTH_CLIENT_ID",
resolve_var(local_env, &["GITHUB_OAUTH_CLIENT_ID"]),
);
insert_if_present(
&mut vars,
"GITHUB_OAUTH_CLIENT_SECRET",
resolve_var(local_env, &["GITHUB_OAUTH_CLIENT_SECRET"]),
);
insert_if_present(
&mut vars,
"GITHUB_APP_CLIENT_ID",
resolve_var(local_env, &["GITHUB_APP_CLIENT_ID", "GITHUB_CLIENT_ID"]),
);
insert_if_present(
&mut vars,
"GITHUB_APP_CLIENT_SECRET",
resolve_var(
local_env,
&["GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_SECRET"],
),
);
insert_if_present(
&mut vars,
"GITHUB_APP_ID",
resolve_var(local_env, &["GITHUB_APP_ID"]),
);
insert_if_present(
&mut vars,
"GITHUB_APP_PRIVATE_KEY",
resolve_var(local_env, &["GITHUB_APP_PRIVATE_KEY", "GITHUB_PRIVATE_KEY"]),
);
insert_if_present(
&mut vars,
"GITHUB_APP_SLUG",
resolve_var(local_env, &["GITHUB_APP_SLUG"]),
);
insert_if_present(
&mut vars,
"GITHUB_WEBHOOK_SECRET",
resolve_var(local_env, &["GITHUB_WEBHOOK_SECRET"]),
);
insert_if_present(
&mut vars,
"BETTER_AUTH_SECRET",
Some(
resolve_var(local_env, &["BETTER_AUTH_SECRET"]).unwrap_or_else(generate_random_secret),
),
);
insert_if_present(
&mut vars,
"BETTER_AUTH_URL",
resolve_var(local_env, &["BETTER_AUTH_URL"]),
);
insert_if_present(
&mut vars,
"R2_PUBLIC_BASE_URL",
resolve_var(local_env, &["R2_PUBLIC_BASE_URL"]),
);
insert_if_present(
&mut vars,
"DEV_TUNNEL_URL",
resolve_var(local_env, &["DEV_TUNNEL_URL"]),
);
insert_if_present(
&mut vars,
"XBP_CONTROL_PLANE_API_URL",
resolve_var(local_env, &["XBP_CONTROL_PLANE_API_URL", "API_XBP_URL"]),
);
insert_if_present(
&mut vars,
"XBP_CONTROL_PLANE_UPSTREAM_URL",
resolve_var(
local_env,
&["XBP_CONTROL_PLANE_UPSTREAM_URL", "API_XBP_URL"],
),
);
insert_if_present(
&mut vars,
"XBP_API_SHARED_TOKEN",
resolve_var(local_env, &["XBP_API_SHARED_TOKEN"]),
);
if !vars.contains_key("BETTER_AUTH_URL") && !fallback_worker_name.trim().is_empty() {
vars.insert(
"BETTER_AUTH_URL".to_string(),
format!("https://{}.workers.dev", fallback_worker_name.trim()),
);
}
if let (Some(oauth_id), Some(app_id)) = (
vars.get("GITHUB_OAUTH_CLIENT_ID"),
vars.get("GITHUB_APP_CLIENT_ID"),
) {
if oauth_id == app_id {
return Err(
"GITHUB_OAUTH_CLIENT_ID and GITHUB_APP_CLIENT_ID must be different. Use OAuth App credentials for GITHUB_OAUTH_* and GitHub App credentials for GITHUB_APP_*.".to_string(),
);
}
}
Ok(vars)
}
pub fn generate_dashboard_config_value(deploy_env: &ResolvedDeployEnv) -> Value {
let mut config = json!({
"$schema": "node_modules/wrangler/config-schema.json",
"compatibility_date": deploy_env.compatibility_date,
"compatibility_flags": [
"nodejs_compat",
"no_handle_cross_request_promise_resolution"
],
"main": "src/entry-worker.ts",
"observability": {
"enabled": false,
"head_sampling_rate": 1,
"logs": {
"enabled": true,
"head_sampling_rate": 1,
"persist": true,
"invocation_logs": true
},
"traces": {
"enabled": true,
"persist": true,
"head_sampling_rate": 1
}
},
"durable_objects": {
"bindings": [
{
"name": "SIGNAL_RELAY",
"class_name": "SignalRelay"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": ["SignalRelay"]
}
],
"name": deploy_env.worker_name,
"d1_databases": [
{
"binding": "DB",
"database_name": deploy_env.d1_name,
"database_id": deploy_env.d1_id,
"migrations_dir": "drizzle"
}
],
"kv_namespaces": [
{
"binding": "GITHUB_CACHE_KV",
"id": deploy_env.kv_id,
"preview_id": deploy_env.kv_preview_id
}
],
"r2_buckets": [
{
"binding": "COMMENT_MEDIA",
"bucket_name": deploy_env.r2_bucket,
"preview_bucket_name": deploy_env.r2_preview_bucket,
"remote": true
}
]
});
if let Some(account_id) = deploy_env.account_id.as_ref() {
config["account_id"] = Value::String(account_id.clone());
}
config
}
pub fn apply_remote_settings_to_config(
config: &mut Value,
remote_settings: Option<&CloudflareWorkerSettings>,
fallback_routes: &[Value],
worker_vars: &HashMap<String, String>,
) {
let mut merged_vars = read_remote_plain_text_bindings(remote_settings);
if let Some(existing_vars) = config.get("vars").and_then(Value::as_object) {
for (key, value) in existing_vars {
if let Some(text) = value.as_str() {
merged_vars.insert(key.clone(), text.to_string());
}
}
}
for (key, value) in worker_vars {
merged_vars.insert(key.clone(), value.clone());
}
config["vars"] = Value::Object(
merged_vars
.into_iter()
.map(|(key, value)| (key, Value::String(value)))
.collect::<Map<String, Value>>(),
);
if let Some(settings) = remote_settings {
if !settings.routes.is_empty() {
config["routes"] = Value::Array(settings.routes.clone());
} else if !fallback_routes.is_empty() {
config["routes"] = Value::Array(fallback_routes.to_vec());
}
if let Some(workers_dev) = settings.workers_dev {
config["workers_dev"] = Value::Bool(workers_dev);
}
if let Some(preview_urls) = settings.preview_urls {
config["preview_urls"] = Value::Bool(preview_urls);
}
if let Some(remote_observability) = settings.observability.as_ref() {
let mut merged = remote_observability.clone();
if let Some(existing) = config.get("observability").cloned() {
merge_json(&mut merged, &existing);
}
config["observability"] = merged;
}
} else if !fallback_routes.is_empty() {
config["routes"] = Value::Array(fallback_routes.to_vec());
}
}
pub fn run_d1_migrations_apply(
worker_root: &Path,
cmd: &WorkersD1MigrationsApplyCmd,
) -> Result<(), String> {
if !cmd.local && !cmd.remote {
return Err("Choose one of `--local` or `--remote`.".to_string());
}
let mut args = vec![
"d1".to_string(),
"migrations".to_string(),
"apply".to_string(),
cmd.database.clone(),
];
if let Some(config) = cmd.config.as_ref() {
args.push("--config".to_string());
args.push(render_path_arg(worker_root, config));
} else if cmd.local {
if let Some(config_path) = resolve_wrangler_config_path(worker_root, "serve", "development")
{
args.push("--config".to_string());
args.push(config_path);
}
}
if let Some(environment) = cmd
.target
.environment
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push("--env".to_string());
args.push(environment.to_string());
}
if cmd.local {
args.push("--local".to_string());
if let Some(persist_to) = cmd.persist_to.as_ref() {
args.push("--persist-to".to_string());
args.push(render_path_arg(worker_root, persist_to));
} else if !cmd.no_shared_worktree_state && is_worktree_checkout(worker_root)? {
let shared_state = get_shared_wrangler_state_path(worker_root)?;
args.push("--persist-to".to_string());
args.push(shared_state.to_string_lossy().to_string());
}
}
if cmd.remote {
args.push("--remote".to_string());
}
run_wrangler(worker_root, &args)
}
#[derive(Debug, Clone)]
pub struct WranglerOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
pub fn run_wrangler(worker_root: &Path, args: &[String]) -> Result<(), String> {
let output = run_wrangler_capture(worker_root, args)?;
if !output.stdout.trim().is_empty() {
print!("{}", output.stdout);
}
if !output.stderr.trim().is_empty() {
eprint!("{}", output.stderr);
}
if output.exit_code != 0 {
return Err(format_wrangler_failure(args, &output));
}
Ok(())
}
pub fn run_wrangler_capture(worker_root: &Path, args: &[String]) -> Result<WranglerOutput, String> {
ensure_wrangler_auth_for_args(worker_root, args)?;
let mut command = build_wrangler_command(worker_root, args)?;
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let child = command
.spawn()
.map_err(|error| format!("Failed to spawn wrangler: {}", error))?;
let output = child
.wait_with_output()
.map_err(|error| format!("Failed to wait for wrangler: {}", error))?;
Ok(WranglerOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
exit_code: output.status.code().unwrap_or(1),
})
}
pub fn run_wrangler_stream<F>(
worker_root: &Path,
args: &[String],
mut on_line: F,
) -> Result<(), String>
where
F: FnMut(&str) -> Result<(), String>,
{
ensure_wrangler_auth_for_args(worker_root, args)?;
let mut command = build_wrangler_command(worker_root, args)?;
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn wrangler: {}", error))?;
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let line =
line.map_err(|error| format!("Failed to read wrangler output: {}", error))?;
on_line(&line)?;
}
}
if let Some(stderr) = child.stderr.take() {
let reader = BufReader::new(stderr);
for line in reader.lines() {
let line =
line.map_err(|error| format!("Failed to read wrangler stderr: {}", error))?;
on_line(&line)?;
}
}
let status = child
.wait()
.map_err(|error| format!("Failed to wait for wrangler: {}", error))?;
if !status.success() {
return Err(format!(
"Wrangler command failed with status {}.\n{}",
status,
wrangler_auth_hint()
));
}
Ok(())
}
fn wrangler_args_need_cloudflare_auth(args: &[String]) -> bool {
let first = args
.iter()
.map(|s| s.trim().to_ascii_lowercase())
.find(|s| !s.is_empty() && !s.starts_with('-'));
let always_remote = matches!(
first.as_deref(),
Some(
"tail"
| "deploy"
| "delete"
| "secret"
| "secrets"
| "versions"
| "triggers"
| "whoami"
| "deployments"
| "rollback"
| "containers"
| "workflows"
| "pages"
| "queues"
| "hyperdrive"
| "vectorize"
| "pubsub"
| "tunnel"
)
);
let remote_flag = args.iter().any(|a| {
let a = a.as_str();
a == "--remote" || a == "--x-remote" || a.starts_with("--remote=")
});
always_remote || remote_flag
}
fn ensure_wrangler_auth_for_args(worker_root: &Path, args: &[String]) -> Result<(), String> {
if !wrangler_args_need_cloudflare_auth(args) {
return Ok(());
}
let auth = resolve_wrangler_cloudflare_auth(worker_root);
if auth.api_token.is_some() {
return Ok(());
}
Err(format!(
"No Cloudflare API token available for Wrangler (`{}`).\n{}",
args.join(" "),
wrangler_auth_hint()
))
}
fn wrangler_auth_hint() -> String {
"XBP injects CLOUDFLARE_API_TOKEN into Wrangler from (in order): process env, worker .env/.env.local, then global `~/.xbp/config.yaml` (`xbp config cloudflare set-key`).\n\
If logs show `grant_type=refresh_token` / `Failed to fetch auth token: 400`, Wrangler used a stale OAuth session under `%APPDATA%/xdg.config/.wrangler/config/default.toml` instead of an API token — ensure you run a current xbp build, or run `wrangler logout` and rely on `xbp config cloudflare set-key`.\n\
Run `xbp config cloudflare show-key` to verify the stored token.".to_string()
}
fn build_wrangler_command(worker_root: &Path, args: &[String]) -> Result<Command, String> {
let wrangler_js = worker_root
.join("node_modules")
.join("wrangler")
.join("bin")
.join("wrangler.js");
let wrangler_bin_js = worker_root
.join("node_modules")
.join(".bin")
.join(if cfg!(windows) {
"wrangler.cmd"
} else {
"wrangler"
});
let mut command = if wrangler_js.exists() {
let mut command = Command::new("node");
command.arg(&wrangler_js).args(args).current_dir(worker_root);
command
} else if wrangler_bin_js.exists() {
let mut command = Command::new(&wrangler_bin_js);
command.args(args).current_dir(worker_root);
command
} else {
let pnpm_bin = if cfg!(windows) { "pnpm.cmd" } else { "pnpm" };
let mut command = Command::new(pnpm_bin);
command
.args(["exec", "wrangler"])
.args(args)
.current_dir(worker_root);
command
};
inject_wrangler_cloudflare_env(&mut command, worker_root);
Ok(command)
}
fn inject_wrangler_cloudflare_env(command: &mut Command, worker_root: &Path) {
let auth = resolve_wrangler_cloudflare_auth(worker_root);
if let Some(token) = auth.api_token.as_ref() {
command.env("CLOUDFLARE_API_TOKEN", token);
command.env("CI", "1");
neutralize_stale_wrangler_oauth_session();
}
if let Some(account_id) = auth.account_id {
command.env("CLOUDFLARE_ACCOUNT_ID", account_id);
}
}
fn neutralize_stale_wrangler_oauth_session() {
for path in wrangler_oauth_config_paths() {
if !path.is_file() {
continue;
}
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
if !content.contains("refresh_token") && !content.contains("oauth_token") {
continue;
}
let bak = path.with_extension("toml.xbp-bak");
if !bak.exists() {
let _ = fs::copy(&path, &bak);
}
let _ = fs::write(
&path,
"# Cleared by xbp: use CLOUDFLARE_API_TOKEN from ~/.xbp/config.yaml (xbp config cloudflare set-key)\n",
);
}
}
fn wrangler_oauth_config_paths() -> Vec<std::path::PathBuf> {
let mut paths = Vec::new();
if let Some(home) = dirs::home_dir() {
paths.push(
home.join(".wrangler")
.join("config")
.join("default.toml"),
);
}
if let Some(config_home) = dirs::config_dir() {
paths.push(
config_home
.join("xdg.config")
.join(".wrangler")
.join("config")
.join("default.toml"),
);
paths.push(
config_home
.join(".wrangler")
.join("config")
.join("default.toml"),
);
}
if let Ok(appdata) = env::var("APPDATA") {
paths.push(
std::path::PathBuf::from(appdata)
.join("xdg.config")
.join(".wrangler")
.join("config")
.join("default.toml"),
);
}
paths
}
#[derive(Debug, Clone, Default)]
struct WranglerCloudflareAuth {
api_token: Option<String>,
account_id: Option<String>,
}
pub fn describe_wrangler_cloudflare_auth(worker_root: &Path) -> String {
use crate::config::{
cloudflare_account_id_from_global_config, cloudflare_account_id_from_process_env,
cloudflare_account_id_from_project_env, resolve_cloudflare_api_token, SshConfig,
SecretProvider,
};
use super::project::parse_multiline_env_file;
let local_env = parse_multiline_env_file(&worker_root.join(".env.local"))
.ok()
.or_else(|| parse_multiline_env_file(&worker_root.join(".env")).ok())
.unwrap_or_default();
let local_token = local_env
.get("CLOUDFLARE_API_TOKEN")
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.is_some();
let process_token = std::env::var("CLOUDFLARE_API_TOKEN")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.is_some();
let global_token = SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Cloudflare).map(str::to_string))
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.is_some();
let resolved = resolve_wrangler_cloudflare_auth(worker_root);
let token_source = if process_token {
"process env CLOUDFLARE_API_TOKEN"
} else if local_token {
"worker .env/.env.local"
} else if global_token || resolve_cloudflare_api_token().is_some() {
"global ~/.xbp/config.yaml (xbp config cloudflare set-key)"
} else {
"none"
};
let account_source = if cloudflare_account_id_from_process_env().is_some() {
"process env"
} else if cloudflare_account_id_from_project_env().is_some() {
"project env"
} else if cloudflare_account_id_from_global_config().is_some() {
"global config"
} else if local_env
.get("CLOUDFLARE_ACCOUNT_ID")
.map(|v| !v.trim().is_empty())
.unwrap_or(false)
{
"worker .env/.env.local"
} else {
"none"
};
let token_preview = resolved
.api_token
.as_ref()
.map(|t| {
let t = t.trim();
if t.len() <= 8 {
"********".to_string()
} else {
format!("{}…{} (len={})", &t[..4], &t[t.len() - 4..], t.len())
}
})
.unwrap_or_else(|| "missing".to_string());
format!(
"Wrangler auth: token={token_preview} via {token_source}; account_id={} via {account_source}",
if resolved.account_id.is_some() {
"set"
} else {
"missing"
}
)
}
fn resolve_wrangler_cloudflare_auth(worker_root: &Path) -> WranglerCloudflareAuth {
use crate::config::{
resolve_cloudflare_account_id, resolve_cloudflare_api_token, SshConfig, SecretProvider,
};
use super::project::parse_multiline_env_file;
let local_env = parse_multiline_env_file(&worker_root.join(".env.local"))
.ok()
.or_else(|| parse_multiline_env_file(&worker_root.join(".env")).ok())
.unwrap_or_default();
let local_token = local_env
.get("CLOUDFLARE_API_TOKEN")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let local_account = local_env
.get("CLOUDFLARE_ACCOUNT_ID")
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let api_token = resolve_cloudflare_api_token()
.or(local_token)
.or_else(|| {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.get_secret(SecretProvider::Cloudflare).map(str::to_string))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
});
let account_id = resolve_cloudflare_account_id()
.or(local_account)
.or_else(|| {
SshConfig::load()
.ok()
.and_then(|cfg| cfg.cloudflare_account_id.clone())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
});
WranglerCloudflareAuth {
api_token,
account_id,
}
}
pub(crate) fn format_wrangler_failure(args: &[String], output: &WranglerOutput) -> String {
let joined = args.join(" ");
let mut message = format!(
"Wrangler command failed (exit {}): wrangler {}",
output.exit_code, joined
);
if !output.stderr.trim().is_empty() {
message.push_str("\nstderr:\n");
message.push_str(output.stderr.trim());
} else if !output.stdout.trim().is_empty() {
message.push_str("\noutput:\n");
message.push_str(output.stdout.trim());
}
let combined = format!("{}{}", output.stderr, output.stdout).to_ascii_lowercase();
if combined.contains("cloudflare_api_token")
|| combined.contains("failed to fetch auth token")
|| combined.contains("non-interactive environment")
{
message.push('\n');
message.push_str(&wrangler_auth_hint());
}
message
}
pub fn write_json_file(path: &Path, value: &Value) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Err(format!(
"Could not resolve parent directory for {}",
path.display()
));
};
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {}", parent.display(), error))?;
let content = format!(
"{}\n",
serde_json::to_string_pretty(value)
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
fs::write(path, content)
.map_err(|error| format!("Failed to write {}: {}", path.display(), error))
}
pub fn render_path_arg(worker_root: &Path, path: &Path) -> String {
if path.is_absolute() {
path.to_string_lossy().to_string()
} else {
worker_root.join(path).to_string_lossy().to_string()
}
}
fn resolve_var(local_env: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
pick_first_non_empty(
keys.iter()
.map(|key| local_env.get(*key).cloned().or_else(|| env::var(key).ok())),
)
}
fn require_var(local_env: &HashMap<String, String>, key: &str) -> Result<String, String> {
resolve_var(local_env, &[key]).ok_or_else(|| {
format!(
"Missing required deploy value: {}. Set it in .env.local or the environment.",
key
)
})
}
fn insert_if_present(target: &mut HashMap<String, String>, key: &str, value: Option<String>) {
if let Some(value) = value.filter(|value| !value.trim().is_empty()) {
target.insert(key.to_string(), value);
}
}
fn generate_random_secret() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
fn read_remote_plain_text_bindings(
remote_settings: Option<&CloudflareWorkerSettings>,
) -> HashMap<String, String> {
let mut bindings = HashMap::new();
let Some(remote_settings) = remote_settings else {
return bindings;
};
for binding in &remote_settings.bindings {
let Some(binding_type) = binding.get("type").and_then(Value::as_str) else {
continue;
};
if binding_type != "plain_text" {
continue;
}
let Some(name) = binding.get("name").and_then(Value::as_str) else {
continue;
};
let Some(text) = binding.get("text").and_then(Value::as_str) else {
continue;
};
bindings.insert(name.to_string(), text.to_string());
}
bindings
}
fn merge_json(target: &mut Value, overlay: &Value) {
match (target, overlay) {
(Value::Object(target_obj), Value::Object(overlay_obj)) => {
for (key, overlay_value) in overlay_obj {
match target_obj.get_mut(key) {
Some(target_value) => merge_json(target_value, overlay_value),
None => {
target_obj.insert(key.clone(), overlay_value.clone());
}
}
}
}
(target_value, overlay_value) => {
*target_value = overlay_value.clone();
}
}
}
pub fn build_env_summary(
worker_root: &Path,
target_script_name: &str,
local_env: &HashMap<String, String>,
show_values: bool,
) -> Result<Value, String> {
let worker_name = resolve_dashboard_worker_name(worker_root, local_env);
let deploy_env = resolve_deploy_env(worker_root, local_env).ok();
let bindings = build_worker_plain_text_bindings(local_env, &worker_name).unwrap_or_default();
let masked_bindings = bindings
.into_iter()
.map(|(key, value)| {
let display = if show_values {
Value::String(value)
} else {
Value::String("<hidden>".to_string())
};
(key, display)
})
.collect::<Map<String, Value>>();
Ok(json!({
"worker_root": worker_root,
"worker_name": worker_name,
"remote_script_name": target_script_name,
"local_env_path": worker_root.join(".env.local"),
"local_env_present": worker_root.join(".env.local").exists(),
"selected_local_dev_config": resolve_wrangler_config_path(worker_root, "serve", "development"),
"custom_domain_routes": read_configured_custom_domain_routes(worker_root),
"plain_text_bindings": Value::Object(masked_bindings),
"deploy_env": deploy_env.as_ref().map(|value| json!({
"compatibility_date": value.compatibility_date,
"d1_database_name": value.d1_name,
"d1_database_id": value.d1_id,
"kv_id": value.kv_id,
"kv_preview_id": value.kv_preview_id,
"r2_bucket": value.r2_bucket,
"r2_preview_bucket": value.r2_preview_bucket,
"account_id": value.account_id,
})),
}))
}
#[cfg(test)]
mod tests {
use super::{
build_worker_plain_text_bindings, generate_dashboard_config_value,
wrangler_args_need_cloudflare_auth, ResolvedDeployEnv,
};
use serde_json::Value;
use std::collections::HashMap;
#[test]
fn remote_wrangler_verbs_require_auth() {
assert!(wrangler_args_need_cloudflare_auth(&[
"tail".into(),
"athena-auth".into()
]));
assert!(wrangler_args_need_cloudflare_auth(&[
"deploy".into(),
"--env".into(),
"production".into()
]));
assert!(wrangler_args_need_cloudflare_auth(&[
"d1".into(),
"migrations".into(),
"apply".into(),
"DB".into(),
"--remote".into()
]));
assert!(!wrangler_args_need_cloudflare_auth(&[
"d1".into(),
"migrations".into(),
"apply".into(),
"DB".into(),
"--local".into()
]));
}
#[test]
fn build_worker_plain_text_bindings_rejects_duplicate_client_ids() {
let mut env = HashMap::new();
env.insert("GITHUB_OAUTH_CLIENT_ID".to_string(), "same".to_string());
env.insert("GITHUB_APP_CLIENT_ID".to_string(), "same".to_string());
let error = build_worker_plain_text_bindings(&env, "xbp").expect_err("duplicate ids");
assert!(error.contains("must be different"));
}
#[test]
fn build_worker_plain_text_bindings_reuses_api_xbp_url_for_upstream() {
let mut env = HashMap::new();
env.insert(
"XBP_CONTROL_PLANE_API_URL".to_string(),
"https://api.xbp.app".to_string(),
);
env.insert(
"API_XBP_URL".to_string(),
"https://control-plane.example.com".to_string(),
);
let bindings = build_worker_plain_text_bindings(&env, "xbp").expect("bindings");
assert_eq!(
bindings
.get("XBP_CONTROL_PLANE_API_URL")
.map(String::as_str),
Some("https://api.xbp.app")
);
assert_eq!(
bindings
.get("XBP_CONTROL_PLANE_UPSTREAM_URL")
.map(String::as_str),
Some("https://control-plane.example.com")
);
}
#[test]
fn generate_dashboard_config_value_wires_core_bindings() {
let config = generate_dashboard_config_value(&ResolvedDeployEnv {
compatibility_date: "2025-09-02".to_string(),
worker_name: "xbp".to_string(),
d1_name: "xbp-db".to_string(),
d1_id: "db_123".to_string(),
kv_id: "kv_123".to_string(),
kv_preview_id: "kv_preview_123".to_string(),
r2_bucket: "media".to_string(),
r2_preview_bucket: "media-preview".to_string(),
account_id: Some("acc_123".to_string()),
});
assert_eq!(config["name"], Value::String("xbp".to_string()));
assert_eq!(config["d1_databases"][0]["binding"], "DB");
assert_eq!(config["kv_namespaces"][0]["binding"], "GITHUB_CACHE_KV");
assert_eq!(config["r2_buckets"][0]["binding"], "COMMENT_MEDIA");
}
#[test]
fn generate_dashboard_config_value_omits_account_id_when_missing() {
let config = generate_dashboard_config_value(&ResolvedDeployEnv {
compatibility_date: "2025-09-02".to_string(),
worker_name: "xbp".to_string(),
d1_name: "xbp-db".to_string(),
d1_id: "db_123".to_string(),
kv_id: "kv_123".to_string(),
kv_preview_id: "kv_preview_123".to_string(),
r2_bucket: "media".to_string(),
r2_preview_bucket: "media-preview".to_string(),
account_id: None,
});
assert!(config.get("account_id").is_none());
}
}