use super::project::{
discover_worker_apps, resolve_project_script_names, script_belongs_to_project,
worker_root_for_script, DiscoveredWorkerApp,
};
use super::render::is_running_status;
use super::render::{
color_enabled, color_status_text, emit_log_lines, extract_build_log_lines, format_timestamp,
is_failed_status, pad, print_unicode_table, truncate_middle, LogRenderOptions, OutputSink,
};
use super::wrangler::{run_wrangler_capture, run_wrangler_stream, write_json_file};
use super::{
build_cloudflare_client, load_local_env, normalize_cloudflare_account_id,
resolve_cloudflare_credentials, resolve_target_script_name,
};
use crate::cli::commands::WorkersLogsCmd;
use crate::commands::cloudflare_config::is_interactive_terminal;
use crate::provider_support::{CloudflareClient, CloudflareWorkerBuild, CloudflareWorkerScript};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, FuzzySelect};
use serde_json::Value;
use std::env;
use std::path::Path;
use std::time::Duration;
pub async fn run_worker_logs(
root_override: Option<&Path>,
token_override: Option<&str>,
account_id_override: Option<&str>,
cmd: WorkersLogsCmd,
) -> Result<(), String> {
let start = root_override
.map(Path::to_path_buf)
.or_else(|| env::current_dir().ok())
.ok_or_else(|| "Failed to resolve current directory.".to_string())?;
let apps = discover_worker_apps(&start);
let project_scripts = resolve_project_script_names(&apps);
let local_env = apps
.first()
.map(|app| load_local_env(&app.root))
.transpose()?
.unwrap_or_default();
let client = build_cloudflare_client(token_override, account_id_override, &local_env)?;
let scripts = client.list_worker_scripts().await?;
let scoped_scripts = filter_scripts(&scripts, &project_scripts);
let script_name = resolve_script_name(
&start,
&apps,
&cmd,
&scoped_scripts,
&scripts,
&project_scripts,
)?;
if cmd.build {
return show_build_logs(&client, &scripts, &script_name, &cmd).await;
}
let worker_root = worker_root_for_script(&script_name, &apps)
.or_else(|| apps.first().map(|app| app.root.as_path()))
.ok_or_else(|| {
format!(
"Could not resolve a Workers project root for `{}`. Pass `xbp workers --root <path> logs`.",
script_name
)
})?;
if cmd.follow {
ensure_tail_wrangler_config_uses_real_account(
worker_root,
token_override,
account_id_override,
&local_env,
)?;
tail_runtime_logs(worker_root, &script_name, &cmd)?;
return Ok(());
}
show_deployment_status(worker_root, &script_name, &cmd)
}
fn filter_scripts<'a>(
scripts: &'a [CloudflareWorkerScript],
project_scripts: &[String],
) -> Vec<&'a CloudflareWorkerScript> {
if project_scripts.is_empty() {
return scripts.iter().collect();
}
scripts
.iter()
.filter(|script| script_belongs_to_project(&script.id, project_scripts))
.collect()
}
fn resolve_script_name(
start: &Path,
apps: &[DiscoveredWorkerApp],
cmd: &WorkersLogsCmd,
scoped_scripts: &[&CloudflareWorkerScript],
all_scripts: &[CloudflareWorkerScript],
project_scripts: &[String],
) -> Result<String, String> {
if let Some(script) = cmd
.target
.script
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(script.to_string());
}
if let Some(script) = cmd
.script_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Ok(script.to_string());
}
if let Some(worker_root) = apps.first().map(|app| app.root.as_path()) {
let local_env = load_local_env(worker_root).unwrap_or_default();
let resolved = resolve_target_script_name(worker_root, &cmd.target, &local_env);
if all_scripts.iter().any(|script| script.id == resolved) {
return Ok(resolved);
}
} else if let Ok(worker_root) = super::project::resolve_workers_project_root(Some(start), None)
{
let local_env = load_local_env(&worker_root).unwrap_or_default();
let resolved = resolve_target_script_name(&worker_root, &cmd.target, &local_env);
if all_scripts.iter().any(|script| script.id == resolved) {
return Ok(resolved);
}
}
let candidates: Vec<&CloudflareWorkerScript> = if scoped_scripts.is_empty() {
all_scripts.iter().collect()
} else {
scoped_scripts.to_vec()
};
if candidates.is_empty() {
return Err("No Workers found in this Cloudflare account.".to_string());
}
if candidates.len() == 1 {
return Ok(candidates[0].id.clone());
}
if !is_interactive() {
let names: Vec<String> = candidates.iter().map(|script| script.id.clone()).collect();
return Err(format!(
"Multiple Workers matched and no worker was specified. Pass `--worker <name>` or a script name. Available: {}",
names.join(", ")
));
}
let labels: Vec<String> = candidates
.iter()
.map(|script| format_worker_choice(script, project_scripts))
.collect();
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select a Worker to inspect")
.items(&labels)
.default(0)
.interact()
.map_err(|error| format!("Failed to run Worker selection prompt: {}", error))?;
Ok(candidates[selection].id.clone())
}
fn is_interactive() -> bool {
is_interactive_terminal() && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
}
fn format_worker_choice(script: &CloudflareWorkerScript, project_scripts: &[String]) -> String {
let scope =
if project_scripts.is_empty() || script_belongs_to_project(&script.id, project_scripts) {
"project"
} else {
"account"
};
format!("{} ({scope})", script.id)
}
fn log_render_options(cmd: &WorkersLogsCmd) -> LogRenderOptions {
LogRenderOptions::from_flags(cmd.lines, cmd.grep.clone(), cmd.errors_only, cmd.no_color)
}
async fn show_build_logs(
client: &CloudflareClient,
scripts: &[CloudflareWorkerScript],
script_name: &str,
cmd: &WorkersLogsCmd,
) -> Result<(), String> {
let script = scripts
.iter()
.find(|script| script.id == script_name)
.ok_or_else(|| format!("Worker script `{}` was not found.", script_name))?;
let tag = script
.tag
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
format!(
"Worker `{}` has no build tag. Workers Builds may not be configured for it.",
script_name
)
})?;
let builds = client.list_worker_builds(tag).await?;
if builds.is_empty() {
println!(
"{}",
format!("No Workers Builds history found for `{}`.", script_name).dimmed()
);
return Ok(());
}
let color = color_enabled(cmd.no_color);
if cmd.list_builds {
print_builds_table(&builds, color);
}
let build = select_build(&builds, cmd)?;
let build = wait_for_build_completion(client, tag, build, cmd).await?;
let status = build
.status
.clone()
.unwrap_or_else(|| "unknown".to_string());
if is_interactive() {
print_build_header(&build, &status, color);
}
let logs = client.get_worker_build_logs(&build.build_uuid).await?;
if cmd.json {
let payload = if cmd.output.is_some() {
serde_json::to_string_pretty(&logs)
.map_err(|error| format!("Failed to encode build logs: {}", error))?
} else {
return print_json_stdout(&logs);
};
write_raw_output(cmd, &payload)?;
return Ok(());
}
let lines = extract_build_log_lines(&logs);
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
emit_log_lines(&lines, &mut sink, &log_render_options(cmd))?;
if let Some(path) = sink.path() {
println!(
"{} {}",
"Wrote build logs to".dimmed(),
path.display().to_string().cyan()
);
}
if is_interactive() && is_failed_status(&status) {
println!(
"\n{}",
"Latest matching build failed. Use `xbp workers logs --build --failed` after a new failure to jump to it.".yellow()
);
}
Ok(())
}
async fn wait_for_build_completion(
client: &CloudflareClient,
tag: &str,
build: &CloudflareWorkerBuild,
cmd: &WorkersLogsCmd,
) -> Result<CloudflareWorkerBuild, String> {
if cmd.no_wait || !cmd.wait {
return Ok(build.clone());
}
let status = build.status.as_deref().unwrap_or_default();
if !is_running_status(status) {
return Ok(build.clone());
}
let deadline = std::time::Instant::now() + Duration::from_secs(cmd.wait_seconds.max(1));
let mut latest_build = build.clone();
while std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_secs(2)).await;
let builds = client.list_worker_builds(tag).await?;
let Some(next) = builds
.iter()
.find(|candidate| candidate.build_uuid == latest_build.build_uuid)
else {
break;
};
latest_build = next.clone();
let next_status = next.status.as_deref().unwrap_or_default();
if !is_running_status(next_status) {
return Ok(latest_build);
}
}
Ok(latest_build)
}
fn select_build<'a>(
builds: &'a [CloudflareWorkerBuild],
cmd: &WorkersLogsCmd,
) -> Result<&'a CloudflareWorkerBuild, String> {
if let Some(index) = cmd.build_index {
return builds.get(index).ok_or_else(|| {
format!(
"Build index {} is out of range. {} build(s) available (0 = latest).",
index,
builds.len()
)
});
}
if cmd.failed {
return builds
.iter()
.find(|build| is_failed_status(build.status.as_deref().unwrap_or_default()))
.or_else(|| builds.first())
.ok_or_else(|| "No build records were found.".to_string());
}
builds
.first()
.ok_or_else(|| "No build records were found.".to_string())
}
fn print_build_header(build: &CloudflareWorkerBuild, status: &str, color: bool) {
let status_text = color_status_text(status, status.len(), color);
let branch = build
.branch
.as_deref()
.map(|value| format!("branch={value}"))
.unwrap_or_default();
println!(
"{} {} {} {}",
"Build".bold(),
build.build_uuid.cyan(),
status_text,
branch.dimmed()
);
}
fn print_builds_table(builds: &[CloudflareWorkerBuild], color: bool) {
let headers = ["#", "STATUS", "BRANCH", "CREATED", "BUILD UUID"];
let rows: Vec<Vec<String>> = builds
.iter()
.enumerate()
.map(|(index, build)| {
vec![
index.to_string(),
build
.status
.clone()
.unwrap_or_else(|| "unknown".to_string()),
build
.branch
.clone()
.map(|value| truncate_middle(&value, 24))
.unwrap_or_else(|| "—".to_string()),
format_timestamp(build.created_at.as_deref()),
truncate_middle(&build.build_uuid, 28),
]
})
.collect();
print_unicode_table(
&headers,
&rows,
color,
|row, column, width, color| match column {
1 => color_status_text(&row[1], width, color),
0 => {
let padded = pad(&row[0], width);
if color {
padded.bold().to_string()
} else {
padded
}
}
_ => pad(&row[column], width),
},
);
println!();
}
fn tail_runtime_logs(
worker_root: &Path,
script_name: &str,
cmd: &WorkersLogsCmd,
) -> Result<(), String> {
let mut args = vec!["tail".to_string(), script_name.to_string()];
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 let Some(pattern) = cmd
.grep
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push("--search".to_string());
args.push(pattern.to_string());
}
if cmd.errors_only {
args.push("--status".to_string());
args.push("error".to_string());
}
args.push("--format".to_string());
args.push(if cmd.json {
"json".to_string()
} else {
"pretty".to_string()
});
if is_interactive() {
println!(
"{} {} {}",
"Tailing runtime logs for".dimmed(),
script_name.cyan().bold(),
"(Ctrl+C to stop)".dimmed()
);
}
let options = log_render_options(cmd);
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
run_wrangler_stream(worker_root, &args, |line| {
if cmd.json {
sink.write_plain(line)?;
return Ok(());
}
sink.write_line(line, &options)
})?;
if let Some(path) = sink.path() {
println!(
"{} {}",
"Wrote runtime logs to".dimmed(),
path.display().to_string().cyan()
);
}
Ok(())
}
fn ensure_tail_wrangler_config_uses_real_account(
worker_root: &Path,
token_override: Option<&str>,
account_id_override: Option<&str>,
local_env: &std::collections::HashMap<String, String>,
) -> Result<(), String> {
let Some(config_account_id) = read_default_wrangler_account_id(worker_root)? else {
return Ok(());
};
if normalize_cloudflare_account_id(&config_account_id).is_some() {
return Ok(());
}
let credentials =
resolve_cloudflare_credentials(token_override, account_id_override, local_env)?;
println!(
"{}",
"Detected placeholder account_id in wrangler.jsonc; updating it from XBP Cloudflare credentials before tailing logs.".yellow()
);
write_default_wrangler_account_id(worker_root, &credentials.account_id)?;
let Some(updated_account_id) = read_default_wrangler_account_id(worker_root)? else {
return Err(
"Wrangler config sync completed, but wrangler.jsonc still has no account_id."
.to_string(),
);
};
if normalize_cloudflare_account_id(&updated_account_id).is_none() {
return Err(format!(
"Wrangler config sync completed, but wrangler.jsonc still has placeholder account_id `{}`.",
updated_account_id
));
}
Ok(())
}
fn write_default_wrangler_account_id(worker_root: &Path, account_id: &str) -> Result<(), String> {
for file_name in [
"wrangler.jsonc",
"wrangler.dev.jsonc",
"wrangler.deploy.json",
] {
let path = worker_root.join(file_name);
if !path.exists() {
continue;
}
let content = std::fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let mut value: Value = serde_json::from_str(&strip_json_comments(&content))
.map_err(|error| format!("Failed to parse {} as JSONC: {}", path.display(), error))?;
if value.get("account_id").and_then(Value::as_str).is_some() {
value["account_id"] = Value::String(account_id.to_string());
write_json_file(&path, &value)?;
}
}
Ok(())
}
fn read_default_wrangler_account_id(worker_root: &Path) -> Result<Option<String>, String> {
let path = worker_root.join("wrangler.jsonc");
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let value: Value = serde_json::from_str(&strip_json_comments(&content))
.map_err(|error| format!("Failed to parse {} as JSONC: {}", path.display(), error))?;
Ok(value
.get("account_id")
.and_then(Value::as_str)
.map(str::to_string))
}
fn strip_json_comments(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
continue;
}
if ch == '/' {
match chars.peek().copied() {
Some('/') => {
let _ = chars.next();
for next in chars.by_ref() {
if next == '\n' {
output.push('\n');
break;
}
}
continue;
}
Some('*') => {
let _ = chars.next();
let mut last = '\0';
for next in chars.by_ref() {
if last == '*' && next == '/' {
break;
}
last = next;
}
continue;
}
_ => {}
}
}
output.push(ch);
}
output
}
fn show_deployment_status(
worker_root: &Path,
script_name: &str,
cmd: &WorkersLogsCmd,
) -> Result<(), String> {
if is_interactive() {
println!(
"{} {}",
"Recent deployments for".dimmed(),
script_name.cyan().bold()
);
}
let args = vec![
"deployments".to_string(),
"list".to_string(),
"--name".to_string(),
script_name.to_string(),
"--json".to_string(),
];
let output = run_wrangler_capture(worker_root, &args)?;
if output.exit_code != 0 {
return Err(super::wrangler::format_wrangler_failure(&args, &output));
}
if cmd.json {
let payload = output.stdout.trim();
if cmd.output.is_some() {
write_raw_output(cmd, payload)?;
} else {
println!("{payload}");
}
return Ok(());
}
let mut deployments = parse_deployments_json(&output.stdout);
deployments = filter_deployments(deployments, cmd);
if deployments.is_empty() {
let fallback_lines: Vec<String> = output
.stdout
.lines()
.chain(output.stderr.lines())
.map(str::to_string)
.filter(|line| !line.trim().is_empty())
.collect();
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
emit_log_lines(&fallback_lines, &mut sink, &log_render_options(cmd))?;
if is_interactive() {
print_logs_hint();
}
return Ok(());
}
if is_interactive() && !cmd.json {
print_deployments_table(&deployments, cmd.no_color);
}
let log_lines = deployments_to_log_lines(&deployments);
let mut sink = OutputSink::new(cmd.output.as_deref(), cmd.no_color)?;
if cmd.output.is_some() {
emit_log_lines(&log_lines, &mut sink, &log_render_options(cmd))?;
println!(
"{} {}",
"Wrote deployment log lines to".dimmed(),
sink.path()
.map(|path| path.display().to_string())
.unwrap_or_default()
.cyan()
);
}
if is_interactive() {
print_logs_hint();
}
Ok(())
}
#[derive(Debug, Clone)]
struct DeploymentRow {
id: String,
created_on: String,
source: String,
message: String,
}
fn filter_deployments(
mut deployments: Vec<DeploymentRow>,
cmd: &WorkersLogsCmd,
) -> Vec<DeploymentRow> {
if let Some(pattern) = cmd
.grep
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
let pattern = pattern.to_ascii_lowercase();
deployments.retain(|deployment| {
deployment.id.to_ascii_lowercase().contains(&pattern)
|| deployment.source.to_ascii_lowercase().contains(&pattern)
|| deployment.message.to_ascii_lowercase().contains(&pattern)
});
}
if cmd.errors_only {
deployments.retain(|deployment| {
super::render::looks_like_error_line(&deployment.message)
|| super::render::looks_like_error_line(&deployment.source)
});
}
if let Some(limit) = cmd.lines {
if deployments.len() > limit {
deployments.truncate(limit);
}
}
deployments
}
fn parse_deployments_json(stdout: &str) -> Vec<DeploymentRow> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Vec::new();
}
let value = match serde_json::from_str::<Value>(trimmed) {
Ok(value) => value,
Err(_) => return Vec::new(),
};
let entries = match value {
Value::Array(items) => items,
Value::Object(mut map) => map
.remove("deployments")
.or_else(|| map.remove("result"))
.and_then(|value| match value {
Value::Array(items) => Some(items),
_ => None,
})
.unwrap_or_default(),
_ => Vec::new(),
};
entries
.into_iter()
.filter_map(|entry| {
let Value::Object(map) = entry else {
return None;
};
Some(DeploymentRow {
id: map
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "—".to_string()),
created_on: map
.get("created_on")
.or_else(|| map.get("created"))
.and_then(Value::as_str)
.map(|value| format_timestamp(Some(value)))
.unwrap_or_else(|| "—".to_string()),
source: map
.get("source")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "—".to_string()),
message: map
.get("message")
.or_else(|| map.get("comment"))
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "—".to_string()),
})
})
.collect()
}
fn print_deployments_table(deployments: &[DeploymentRow], no_color: bool) {
let color = color_enabled(no_color);
let headers = ["ID", "CREATED", "SOURCE", "MESSAGE"];
let rows: Vec<Vec<String>> = deployments
.iter()
.map(|deployment| {
vec![
truncate_middle(&deployment.id, 18),
deployment.created_on.clone(),
deployment.source.clone(),
truncate_middle(&deployment.message, 40),
]
})
.collect();
print_unicode_table(&headers, &rows, color, |row, column, width, _| {
pad(&row[column], width)
});
println!();
}
fn deployments_to_log_lines(deployments: &[DeploymentRow]) -> Vec<String> {
deployments
.iter()
.map(|deployment| {
format!(
"{} {} source={} {}",
deployment.created_on, deployment.id, deployment.source, deployment.message
)
})
.collect()
}
fn print_logs_hint() {
println!(
"{}",
"Use `xbp workers logs -f` to stream runtime logs, or `xbp workers logs --build` for Workers Builds CI output.".dimmed()
);
}
fn print_json_stdout(value: &impl serde::Serialize) -> Result<(), String> {
println!(
"{}",
serde_json::to_string_pretty(value)
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
Ok(())
}
fn write_raw_output(cmd: &WorkersLogsCmd, payload: &str) -> Result<(), String> {
let path = cmd
.output
.as_deref()
.ok_or_else(|| "No output path was provided.".to_string())?;
std::fs::write(path, payload)
.map_err(|error| format!("Failed to write {}: {}", path.display(), error))?;
println!(
"{} {}",
"Wrote output to".dimmed(),
path.display().to_string().cyan()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
read_default_wrangler_account_id, strip_json_comments, write_default_wrangler_account_id,
};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-workers-logs-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn wrangler_account_id_rewrite_replaces_placeholder_without_dropping_config() {
let root = temp_dir("account-id");
fs::write(
root.join("wrangler.jsonc"),
r#"{
// generated placeholder
"account_id": "test-account",
"name": "xbp",
"vars": { "PUBLIC_VALUE": "kept" }
}"#,
)
.expect("write wrangler config");
assert_eq!(
read_default_wrangler_account_id(&root).expect("read"),
Some("test-account".to_string())
);
write_default_wrangler_account_id(&root, "a196ad9f98b8a2883f87e0d2d56ec3d5")
.expect("rewrite");
let content = fs::read_to_string(root.join("wrangler.jsonc")).expect("read rewritten");
let value: serde_json::Value =
serde_json::from_str(&strip_json_comments(&content)).expect("json");
assert_eq!(
value["account_id"].as_str(),
Some("a196ad9f98b8a2883f87e0d2d56ec3d5")
);
assert_eq!(value["name"].as_str(), Some("xbp"));
assert_eq!(value["vars"]["PUBLIC_VALUE"].as_str(), Some("kept"));
let _ = fs::remove_dir_all(root);
}
}