use crate::utils::{
detect_js_install_package_manager, ensure_pnpm_workspace_packages, suggested_build_command,
JsPackageManager,
};
use colored::Colorize;
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use xbp_providers::{CloudflareClient, CloudflareWorkerScript};
#[derive(Debug, Clone)]
pub struct BuildCommandSyncReport {
pub script_name: String,
pub desired_build_command: String,
pub package_manager: &'static str,
pub notes: Vec<String>,
pub updated_triggers: Vec<String>,
pub unchanged_triggers: Vec<String>,
pub skipped: Option<String>,
}
pub fn preferred_build_script(worker_root: &Path) -> Option<&'static str> {
let package_json = worker_root.join("package.json");
let content = fs::read_to_string(package_json).ok()?;
let value: Value = serde_json::from_str(&content).ok()?;
let scripts = value.get("scripts")?.as_object()?;
if scripts.contains_key("build:worker") {
Some("build:worker")
} else if scripts.contains_key("build") {
Some("build")
} else {
None
}
}
pub fn desired_workers_build_command(worker_root: &Path) -> Option<String> {
let script = preferred_build_script(worker_root)?;
let _ = ensure_pnpm_workspace_packages(worker_root);
Some(suggested_build_command(worker_root, script))
}
pub fn build_command_needs_update(remote: Option<&str>, desired: &str) -> bool {
let Some(remote) = remote.map(str::trim).filter(|s| !s.is_empty()) else {
return true;
};
if remote == desired {
return false;
}
let remote_pm = infer_pm_from_command(remote);
let desired_pm = infer_pm_from_command(desired);
if remote_pm != desired_pm {
return true;
}
remote != desired
}
fn infer_pm_from_command(cmd: &str) -> JsPackageManager {
let c = cmd.trim_start();
if c.starts_with("bun ") {
JsPackageManager::Bun
} else if c.starts_with("pnpm ") {
JsPackageManager::Pnpm
} else if c.starts_with("yarn ") {
JsPackageManager::Yarn
} else {
JsPackageManager::Npm
}
}
pub async fn sync_workers_builds_build_command(
client: &CloudflareClient,
script_name: &str,
worker_root: &Path,
dry_run: bool,
) -> Result<BuildCommandSyncReport, String> {
sync_workers_builds_build_command_with_root(client, script_name, worker_root, None, dry_run)
.await
}
pub async fn sync_workers_builds_build_command_with_root(
client: &CloudflareClient,
script_name: &str,
worker_root: &Path,
root_directory: Option<&str>,
dry_run: bool,
) -> Result<BuildCommandSyncReport, String> {
sync_workers_builds_build_command_with_root_and_source(
client,
script_name,
worker_root,
root_directory,
dry_run,
None,
None,
)
.await
}
pub async fn sync_workers_builds_build_command_trying_tokens(
script_name: &str,
worker_root: &Path,
root_directory: Option<&str>,
dry_run: bool,
) -> Result<BuildCommandSyncReport, String> {
use crate::commands::cloudflare_credentials::{
cloudflare_credential_try_list_builds_first, CloudflareCredentialOverrides,
};
use crate::config::{classify_cloudflare_token, CloudflareTokenKind};
let candidates = cloudflare_credential_try_list_builds_first(
CloudflareCredentialOverrides::default(),
true,
)
.await?;
let mut last_report: Option<BuildCommandSyncReport> = None;
let mut tried: Vec<String> = Vec::new();
let mut saw_account_token = false;
let mut saw_user_token = false;
for cred in candidates {
let kind = classify_cloudflare_token(&cred.token);
if kind == CloudflareTokenKind::Account {
saw_account_token = true;
}
if kind == CloudflareTokenKind::User {
saw_user_token = true;
}
let client = CloudflareClient::new(cred.token.clone(), cred.account_id.clone())?;
let mut report = sync_workers_builds_build_command_with_root_and_source(
&client,
script_name,
worker_root,
root_directory,
dry_run,
Some(cred.token_source.as_str()),
Some(cred.token.as_str()),
)
.await?;
let builds_auth_failed = report
.skipped
.as_ref()
.map(|s| is_builds_token_scope_error(s))
.unwrap_or(false)
|| report.notes.iter().any(|n| is_builds_token_scope_error(n));
if builds_auth_failed {
tried.push(format!(
"{} [{}]",
cred.token_source,
crate::config::cloudflare_token_kind_label(kind)
));
last_report = Some(report);
continue;
}
if !tried.is_empty() {
report.notes.insert(
0,
format!(
"Builds accepted token from {} (skipped failing sources: {})",
cred.token_source,
tried.join(", ")
),
);
} else {
report.notes.insert(
0,
format!("Builds API token source: {}", cred.token_source),
);
}
return Ok(report);
}
if let Some(mut report) = last_report {
if saw_account_token && !saw_user_token {
report.notes.insert(
0,
"All tried credentials are account-owned (`cfat_`). Workers Builds only \
accepts user tokens (`cfut_`) from My Profile → API Tokens — not \
Manage Account → Account API Tokens. Create a user token, then \
`xbp config cloudflare set-key` (and unset CLOUDFLARE_API_TOKEN if it \
still points at a cfat_ secret)."
.into(),
);
}
report.notes.insert(
0,
format!(
"Tried token source(s): {}. Env often shadows config — unset CLOUDFLARE_API_TOKEN or update it to a Builds-capable *user* token (`cfut_`) via `xbp config cloudflare set-key`.",
tried.join(" → ")
),
);
return Ok(report);
}
Err("No Cloudflare credentials available for Workers Builds sync.".into())
}
pub async fn sync_workers_builds_build_command_with_root_and_source(
client: &CloudflareClient,
script_name: &str,
worker_root: &Path,
root_directory: Option<&str>,
dry_run: bool,
token_source: Option<&str>,
token_for_diagnostics: Option<&str>,
) -> Result<BuildCommandSyncReport, String> {
let pm = detect_js_install_package_manager(worker_root);
let Some(desired) = desired_workers_build_command(worker_root) else {
return Ok(BuildCommandSyncReport {
script_name: script_name.to_string(),
desired_build_command: String::new(),
package_manager: pm.name(),
notes: vec!["no package.json build/build:worker script — skipped".into()],
updated_triggers: vec![],
unchanged_triggers: vec![],
skipped: Some("no build script".into()),
});
};
let scripts = match client.list_worker_scripts().await {
Ok(s) => s,
Err(e) => {
let explained = explain_builds_api_error_for_token(&e, token_for_diagnostics);
let mut notes = vec![explained.clone()];
if let Some(src) = token_source {
notes.insert(0, format!("token source: {src}"));
}
return Ok(BuildCommandSyncReport {
script_name: script_name.to_string(),
desired_build_command: desired,
package_manager: pm.name(),
notes,
updated_triggers: vec![],
unchanged_triggers: vec![],
skipped: Some(explained),
});
}
};
let script = find_script(&scripts, script_name).ok_or_else(|| {
format!(
"Worker script `{script_name}` not found on Cloudflare account (cannot sync Builds)."
)
})?;
let tag = script
.tag
.as_deref()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
format!(
"Worker `{script_name}` has no Builds tag — connect Workers Builds in the dashboard first."
)
})?;
let triggers = match client.list_worker_build_triggers(tag).await {
Ok(t) => t,
Err(e) => {
let explained = explain_builds_api_error_for_token(&e, token_for_diagnostics);
let mut notes = vec![explained.clone()];
if let Some(src) = token_source {
notes.insert(0, format!("token source: {src}"));
}
return Ok(BuildCommandSyncReport {
script_name: script_name.to_string(),
desired_build_command: desired,
package_manager: pm.name(),
notes,
updated_triggers: vec![],
unchanged_triggers: vec![],
skipped: Some(explained),
});
}
};
if triggers.is_empty() {
return Ok(BuildCommandSyncReport {
script_name: script_name.to_string(),
desired_build_command: desired,
package_manager: pm.name(),
notes: vec![
"no Builds triggers — connect Git under Worker → Settings → Builds".into(),
],
updated_triggers: vec![],
unchanged_triggers: vec![],
skipped: Some("no triggers".into()),
});
}
let mut report = BuildCommandSyncReport {
script_name: script_name.to_string(),
desired_build_command: desired.clone(),
package_manager: pm.name(),
notes: Vec::new(),
updated_triggers: Vec::new(),
unchanged_triggers: Vec::new(),
skipped: None,
};
for trigger in &triggers {
let name = trigger
.trigger_name
.clone()
.unwrap_or_else(|| "trigger".into());
let remote = trigger.build_command.as_deref();
let root_needs = root_directory
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|want| {
let have = trigger
.root_directory
.as_deref()
.map(str::trim)
.unwrap_or("");
let normalize = |s: &str| s.trim().trim_matches('/').to_string();
normalize(have) != normalize(want)
})
.unwrap_or(false);
if !build_command_needs_update(remote, &desired) && !root_needs {
report.unchanged_triggers.push(format!(
"{name}: {}",
remote.unwrap_or("(empty)")
));
continue;
}
let Some(id) = trigger.id() else {
report
.notes
.push(format!("{name}: missing trigger_uuid — skipped"));
continue;
};
let from = remote.unwrap_or("(empty)");
if dry_run {
let mut msg = format!("{name}: would set build_command `{from}` → `{desired}`");
if root_needs {
if let Some(rd) = root_directory {
msg.push_str(&format!("; root_directory → `{rd}`"));
}
}
report.updated_triggers.push(msg);
continue;
}
let mut patch = json!({ "build_command": desired });
if root_needs {
if let Some(rd) = root_directory {
patch
.as_object_mut()
.unwrap()
.insert("root_directory".into(), json!(rd));
}
}
match client.update_worker_build_trigger(id, &patch).await {
Ok(updated) => {
report.updated_triggers.push(format!(
"{name}: `{}` → `{}`",
from,
updated
.build_command
.as_deref()
.unwrap_or(desired.as_str())
));
}
Err(e) => {
report.notes.push(format!(
"{name}: {}",
explain_builds_api_error_for_token(&e, token_for_diagnostics)
));
}
}
}
Ok(report)
}
pub fn explain_builds_api_error(error: &str) -> String {
explain_builds_api_error_for_token(error, None)
}
pub fn explain_builds_api_error_for_token(error: &str, token: Option<&str>) -> String {
use crate::config::{classify_cloudflare_token, CloudflareTokenKind};
let lower = error.to_ascii_lowercase();
let is_12006 = lower.contains("12006")
|| (lower.contains("invalid token") && !lower.contains("expired"));
let is_auth = is_12006
|| lower.contains("authentication error")
|| lower.contains("unauthorized")
|| lower.contains("code 10000");
if is_auth {
let kind = token.map(classify_cloudflare_token);
if matches!(kind, Some(CloudflareTokenKind::Account)) {
return format!(
"Workers Builds API rejected the token ({error}). \
This credential is an **account-owned** API token (`cfat_`). \
Workers Builds currently supports **user** tokens only (`cfut_`); \
account-owned token support is not available yet. \
Fix: Cloudflare dashboard → **My Profile** → **API Tokens** (not \
Manage Account → Account API Tokens) → create a *user* token with \
Workers Scripts: Edit, Account Settings: Read, and \
**Workers Builds Configuration: Edit**. \
Then `xbp config cloudflare set-key` (and unset a stale \
CLOUDFLARE_API_TOKEN env). \
Wrangler deploy can still work with the account token. \
Local-only Builds heal: `xbp cloudflare builds fix --app <app> --no-remote`."
);
}
if matches!(kind, Some(CloudflareTokenKind::GlobalKey)) {
return format!(
"Workers Builds API rejected the credential ({error}). \
Global API keys (`cfk_`) are not suitable for Workers Builds. \
Create a *user* API token (`cfut_`) under My Profile → API Tokens \
with Workers Builds Configuration: Edit, then \
`xbp config cloudflare set-key`."
);
}
return format!(
"Workers Builds API rejected the token ({error}). \
Wrangler can still work when the token has Workers Scripts but not Builds. \
Prefer a *user* API token (`cfut_` from My Profile → API Tokens), not an \
account-owned token (`cfat_`). \
Fix: create/edit a user token with Account permission \
**Workers Builds Configuration: Edit** (and Account Settings: Read). \
Then set CLOUDFLARE_API_TOKEN or `xbp config cloudflare set-key`. \
Worker must also be connected under Worker → Settings → Builds. \
Local-only: `xbp cloudflare builds fix --app <app> --no-remote`."
);
}
if lower.contains("not found") || lower.contains("404") {
return format!(
"Builds resource not found ({error}). Connect Git under Worker → Settings → Builds, \
then re-run `xbp cloudflare builds sync --app <app>`."
);
}
format!("Builds API error: {error}")
}
pub fn is_builds_token_scope_error(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("12006")
|| lower.contains("workers builds configuration")
|| lower.contains("builds api rejected the token")
|| lower.contains("account-owned")
|| lower.contains("user tokens only")
|| (lower.contains("invalid token")
&& (lower.contains("build") || lower.contains("12006")))
}
fn find_script<'a>(
scripts: &'a [CloudflareWorkerScript],
name: &str,
) -> Option<&'a CloudflareWorkerScript> {
scripts.iter().find(|s| s.id == name).or_else(|| {
scripts
.iter()
.find(|s| s.id.eq_ignore_ascii_case(name))
})
}
pub fn print_build_command_sync_report(report: &BuildCommandSyncReport) {
println!(
"{} worker={} pm={} desired=`{}`",
"Workers Builds".bright_cyan().bold(),
report.script_name.bright_white(),
report.package_manager.bright_yellow(),
if report.desired_build_command.is_empty() {
"(none)"
} else {
report.desired_build_command.as_str()
}
);
for line in &report.updated_triggers {
println!(" {} {line}", "updated".green());
}
for line in &report.unchanged_triggers {
println!(" {} {line}", "ok".bright_black());
}
for note in &report.notes {
let label = if is_builds_token_scope_error(note) {
"token".red().bold().to_string()
} else {
"note".yellow().to_string()
};
for (i, chunk) in wrap_note(note, 100).into_iter().enumerate() {
if i == 0 {
println!(" {label} {chunk}");
} else {
println!(" {chunk}");
}
}
}
if let Some(skip) = &report.skipped {
if report.updated_triggers.is_empty() && report.unchanged_triggers.is_empty() {
if report.notes.iter().any(|n| n == skip) {
println!(
" {} remote Builds sync skipped (deploy via wrangler still OK)",
"skip".yellow()
);
} else {
println!(" {} {skip}", "skip".yellow());
}
}
}
}
fn wrap_note(text: &str, width: usize) -> Vec<String> {
if text.len() <= width {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current = word.to_string();
continue;
}
if current.len() + 1 + word.len() > width {
lines.push(std::mem::take(&mut current));
current = word.to_string();
} else {
current.push(' ');
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(text.to_string());
}
lines
}
pub fn sync_local_wrangler_build_command(worker_root: &Path) -> Result<Option<String>, String> {
let Some(desired) = desired_workers_build_command(worker_root) else {
return Ok(None);
};
for name in ["wrangler.toml", "wrangler.jsonc", "wrangler.json"] {
let path = worker_root.join(name);
if !path.is_file() {
continue;
}
if name.ends_with(".toml") {
if let Some(msg) = patch_wrangler_toml_build_command(&path, &desired)? {
return Ok(Some(msg));
}
} else if let Some(msg) = patch_wrangler_json_build_command(&path, &desired)? {
return Ok(Some(msg));
}
}
Ok(None)
}
fn patch_wrangler_toml_build_command(path: &Path, desired: &str) -> Result<Option<String>, String> {
let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let re = regex::Regex::new(
r#"(?m)^(\s*command\s*=\s*)(["'])((?:pnpm|npm|yarn|bun)\s+run\s+[^"']+)(["'])\s*$"#,
)
.map_err(|e| e.to_string())?;
if !re.is_match(&raw) {
return Ok(None);
}
let updated = re.replace_all(&raw, |caps: ®ex::Captures| {
let current = caps.get(3).map(|m| m.as_str()).unwrap_or("");
if current == desired {
return caps.get(0).unwrap().as_str().to_string();
}
format!("{}\"{desired}\"", &caps[1])
});
if updated.as_ref() == raw {
return Ok(None);
}
fs::write(path, updated.as_ref()).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(Some(format!(
"updated {} [build] command → `{desired}`",
path.file_name().unwrap().to_string_lossy()
)))
}
fn patch_wrangler_json_build_command(path: &Path, desired: &str) -> Result<Option<String>, String> {
let raw = fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
let stripped = strip_jsonc_line_comments(&raw);
let mut value: Value = match serde_json::from_str(&stripped) {
Ok(v) => v,
Err(_) => return Ok(None),
};
let Some(build) = value.get_mut("build") else {
return Ok(None);
};
let Some(obj) = build.as_object_mut() else {
return Ok(None);
};
let current = obj
.get("command")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
if current.is_empty() || !build_command_needs_update(Some(¤t), desired) {
return Ok(None);
}
obj.insert("command".into(), Value::String(desired.to_string()));
let pretty = serde_json::to_string_pretty(&value).map_err(|e| e.to_string())?;
fs::write(path, format!("{pretty}\n")).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(Some(format!(
"updated {} build.command `{current}` → `{desired}`",
path.file_name().unwrap().to_string_lossy()
)))
}
fn strip_jsonc_line_comments(s: &str) -> String {
s.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
""
} else if let Some(idx) = line.find("//") {
&line[..idx]
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn needs_update_when_pnpm_vs_bun() {
assert!(build_command_needs_update(
Some("pnpm run build"),
"bun run build"
));
assert!(!build_command_needs_update(
Some("bun run build"),
"bun run build"
));
assert!(build_command_needs_update(None, "bun run build"));
}
#[test]
fn explains_12006_as_builds_permission_not_dead_token() {
let msg = explain_builds_api_error("Invalid token (12006)");
assert!(msg.contains("Workers Builds Configuration"), "{msg}");
assert!(
msg.to_ascii_lowercase().contains("wrangler"),
"{msg}"
);
assert!(msg.contains("cfut_"), "{msg}");
assert!(is_builds_token_scope_error(&msg));
assert!(is_builds_token_scope_error("Invalid token (12006)"));
}
#[test]
fn explains_12006_account_token_as_kind_not_just_permission() {
let account = "cfat_abcdefghijklmnopqrstuvwxyz0123456789ABCD";
let msg = explain_builds_api_error_for_token("Invalid token (12006)", Some(account));
assert!(
msg.to_ascii_lowercase().contains("account-owned"),
"{msg}"
);
assert!(msg.contains("cfat_"), "{msg}");
assert!(msg.contains("cfut_"), "{msg}");
assert!(
msg.contains("My Profile"),
"should point at user token UI: {msg}"
);
assert!(is_builds_token_scope_error(&msg));
}
#[test]
fn non_auth_errors_pass_through() {
let msg = explain_builds_api_error("connection reset by peer");
assert!(msg.contains("connection reset"), "{msg}");
assert!(!is_builds_token_scope_error(&msg));
}
}