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 serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;
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_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 config = generate_dashboard_config_value(&deploy_env);
write_json_file(&output_path, &config)?;
println!("Wrote {}", output_path.display());
Ok(())
}
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_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)
}
pub fn run_wrangler(worker_root: &Path, args: &[String]) -> Result<(), String> {
let wrangler_bin = worker_root
.join("node_modules")
.join("wrangler")
.join("bin")
.join("wrangler.js");
let status = if wrangler_bin.exists() {
Command::new("node")
.arg(&wrangler_bin)
.args(args)
.current_dir(worker_root)
.status()
.map_err(|error| format!("Failed to run node {}: {}", wrangler_bin.display(), error))?
} else {
let pnpm_bin = if cfg!(windows) { "pnpm.cmd" } else { "pnpm" };
Command::new(pnpm_bin)
.args(["exec", "wrangler"])
.args(args)
.current_dir(worker_root)
.status()
.map_err(|error| format!("Failed to run {} exec wrangler: {}", pnpm_bin, error))?
};
if !status.success() {
return Err(format!("Wrangler command failed with status {}.", status));
}
Ok(())
}
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, ResolvedDeployEnv,
};
use serde_json::Value;
use std::collections::HashMap;
#[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 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());
}
}