pub mod doctor;
pub mod history_ext;
pub mod interactive;
pub mod map_config;
pub mod oci_build;
pub mod reset;
pub mod setup;
pub use interactive::{
looks_like_local_kube_context, peel_deploy_mode_positional, resolve_deploy_invocation,
DeployInvocationOptions, PeeledDeployArgs, ResolvedDeployInvocation, DEPLOY_MODE_POSITIONALS,
};
pub use reset::{run_deploy_reset, DeployResetRequest};
pub use xbp_deploy::{DeployMode, DeployTarget};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use colored::Colorize;
use xbp_deploy::{
is_kubernetes_provider, unique_service_names, CloudflareDeployAdapter,
CloudflareServiceDeployRequest, DefaultDeployPlanner, DefaultDeployRunner,
DefaultDeployVerifier, DeployContext, DeployFlags, DeployHistoryStore, DeployPlanner,
DeployRunner, DeployVerifier, HistoryEntry,
};
use self::oci_build::CliOciBuildAdapter;
use xbp_k8s::KubectlAdapter;
use xbp_oci::{
resolve_auth_from_candidates, DefaultOciPromoter, DefaultOciResolver, OciAuthRequest, OciClient,
};
use crate::cli::interactive::{print_picker_header, select_one};
use crate::cli::ui;
use crate::commands::cloudflare::deploy_app_by_name;
use crate::commands::service::load_xbp_config_with_root;
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::oci::auth_candidates_for_registry;
use self::map_config::map_project_config;
use self::setup::{ensure_deploy_env_for_target, heal_deploy_groups};
struct CliCloudflareDeployAdapter {
project_root: PathBuf,
}
#[async_trait]
impl CloudflareDeployAdapter for CliCloudflareDeployAdapter {
async fn deploy_service(
&self,
request: CloudflareServiceDeployRequest,
) -> Result<Vec<String>, String> {
crate::commands::cloudflare::deploy_app_by_name_with_options(
Some(self.project_root.as_path()),
&request.worker_app,
request.dry_run,
request.rollout.as_deref(),
request.skip_deploy,
request.allow_unchanged_container_image,
request.prune_old_images,
request.local_build,
request.local_build_only,
)
.await
}
}
#[derive(Debug, Clone)]
pub struct DeployRequest {
pub target: String,
pub env: Option<String>,
pub mode: DeployMode,
pub flags: DeployFlags,
pub debug: bool,
pub revitalize: Option<String>,
pub revitalize_out: Option<std::path::PathBuf>,
pub revitalize_in_place: bool,
pub redact_history: bool,
pub repair_history: bool,
pub engine_check: bool,
pub scan: bool,
}
pub async fn run_deploy(request: DeployRequest) -> Result<(), String> {
let (project_root, mut xbp_config) = load_xbp_config_with_root().await?;
let skip_group_heal = request.engine_check
|| request.repair_history
|| request.redact_history
|| request.revitalize.is_some()
|| matches!(request.mode, DeployMode::History | DeployMode::Status);
if !skip_group_heal {
let group_heals = heal_deploy_groups(&mut xbp_config);
if !group_heals.is_empty() {
println!(
"{} Auto-healed deploy.groups",
"✓".bright_green().bold()
);
for note in &group_heals {
println!(" {} {}", "·".bright_black(), note.dimmed());
}
if let Some(found) = crate::utils::find_xbp_config_upwards(&project_root) {
if let Err(error) =
crate::utils::write_xbp_project_config_at_path(&found.config_path, &xbp_config)
{
eprintln!(
"{} could not write healed deploy groups: {error}",
"!".bright_yellow()
);
} else {
println!(
" {} wrote {}",
"→".bright_cyan(),
found.config_path.display().to_string().dimmed()
);
}
}
}
}
let env = request
.env
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| {
xbp_config
.deploy
.as_ref()
.and_then(|d| d.default_env.clone())
})
.unwrap_or_else(|| "production".into());
let xbp_config = if matches!(
request.mode,
DeployMode::History | DeployMode::Status
) || request.repair_history
|| request.engine_check
|| request.redact_history
|| request.revitalize.is_some()
{
xbp_config
} else {
ensure_deploy_env_for_target(
&project_root,
xbp_config,
&request.target,
&env,
request.flags.yes,
)?
};
let config = map_project_config(&project_root, &xbp_config);
if request.engine_check {
return history_ext::run_engine_check(
&project_root,
&xbp_config.project_name,
&config.history_dir,
&env,
request.flags.history_limit.max(1),
request.scan,
request.flags.json,
);
}
if request.repair_history {
return repair_deploy_history_index(&config.history_dir, request.flags.json);
}
if request.redact_history {
return redact_existing_deploy_history(
&project_root,
&xbp_config,
&config.history_dir,
request.flags.json,
)
.await;
}
if let Some(revitalize_id) = request.revitalize.as_deref() {
return revitalize_deploy_history(
&config.history_dir,
revitalize_id,
request.revitalize_out.as_deref(),
request.revitalize_in_place,
request.flags.json,
)
.await;
}
if matches!(request.mode, DeployMode::History) {
let store = DeployHistoryStore::new(&config.history_dir);
let limit = request.flags.history_limit.max(1);
let entries = store
.list(&request.target, &env, limit)
.map_err(|e| e.to_string())?;
let stats = store
.stats(&request.target, &env, limit)
.map_err(|e| e.to_string())?;
return print_deploy_history_table(
&store,
&entries,
&request.target,
&env,
&xbp_config.project_name,
&stats,
);
}
if matches!(request.mode, DeployMode::Status) {
let store = DeployHistoryStore::new(&config.history_dir);
let entry = store
.latest_status(&request.target, &env)
.map_err(|e| e.to_string())?;
return print_deploy_status_table(
&store,
entry.as_ref(),
&request.target,
&env,
&xbp_config.project_name,
);
}
let target = if request.target.eq_ignore_ascii_case("all") {
DeployTarget::All
} else if config.groups.contains_key(&request.target) {
DeployTarget::Group(request.target.clone())
} else {
DeployTarget::Service(request.target.clone())
};
let ctx = DeployContext {
env: env.clone(),
target,
config: config.clone(),
flags: request.flags.clone(),
mode: request.mode,
};
let oci_resolver = build_oci_resolver(&xbp_config, request.debug);
let planner = DefaultDeployPlanner::new(oci_resolver.clone());
let mut plan = planner.plan(&ctx).await.map_err(|e| e.to_string())?;
plan = interactive::apply_cli_service_filters(
plan,
&request.flags.only_services,
&request.flags.exclude_services,
)?;
let force_ni = request.flags.json || request.flags.output.is_some();
plan = interactive::maybe_edit_plan_services(plan, request.flags.yes, force_ni)?;
let service_count = unique_service_names(&plan).len();
println!(
"{} {} → {} service(s) env={}",
"Deploy".bright_cyan().bold(),
plan.target.label().bright_white().bold(),
service_count.to_string().bright_green(),
env.bright_yellow()
);
println!("{}", plan.render_human());
if let Some(path) = &request.flags.output {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(
path,
serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("write --output: {e}"))?;
println!("{} {}", "wrote".bright_cyan(), path.display());
}
interactive::maybe_confirm_multi_service_apply(
&plan,
request.mode,
request.flags.yes,
force_ni,
)?;
match request.mode {
DeployMode::Plan => {
if request.flags.json {
println!(
"{}",
serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?
);
}
println!();
println!(
"{} {}",
"OK".bright_green().bold(),
"Plan only (no mutations).".bright_white()
);
println!(
" Re-run with {} to apply (same target/env):\n {} deploy {} --env {} --run",
"--run".bright_green().bold(),
"xbp".bright_magenta().bold(),
request.target.bright_white(),
env.bright_yellow(),
);
Ok(())
}
DeployMode::Verify => {
let k8s = Arc::new(KubectlAdapter::new(request.debug));
let verifier = DefaultDeployVerifier { k8s };
let report = verifier.verify(&ctx, &plan).await.map_err(|e| e.to_string())?;
for line in &report.lines {
println!("{line}");
}
for drift in &report.drifts {
println!("{} {drift}", "drift".bright_red());
}
if request.flags.json {
println!(
"{}",
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
);
}
if report.ok {
println!("{}", "Result: ok".bright_green().bold());
Ok(())
} else {
println!("{}", "Result: FAILED".bright_red().bold());
Err(format!("verify failed: {}", report.drifts.join("; ")))
}
}
DeployMode::Run | DeployMode::Promote => {
let k8s: Arc<dyn xbp_k8s::K8sAdapter> =
Arc::new(KubectlAdapter::new(request.debug));
let promoter = build_oci_promoter(&xbp_config);
let confirm: Arc<dyn Fn(String) -> bool + Send + Sync> = Arc::new(|msg| {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return false;
}
let default_yes = msg.contains("local Docker only")
|| msg.contains("like --local-image");
dialoguer::Confirm::new()
.with_prompt(msg)
.default(default_yes)
.interact()
.unwrap_or(false)
});
let cloudflare: Option<Arc<dyn xbp_deploy::CloudflareDeployAdapter>> =
Some(Arc::new(CliCloudflareDeployAdapter {
project_root: project_root.clone(),
}));
if matches!(request.mode, DeployMode::Run) && !request.flags.yes {
history_ext::warn_repeated_deploy_failures(
&config.history_dir,
&request.target,
&env,
3,
);
}
if ctx.should_run_doctor() && matches!(request.mode, DeployMode::Run) {
let report = doctor::run_deploy_doctor(
&ctx,
&plan,
&xbp_config,
&project_root,
k8s.as_ref(),
request.debug,
)
.await;
for line in &report.lines {
println!("{}", line.bright_black());
}
if !report.ok {
let cf_hint = report.lines.iter().any(|l| {
l.contains("cf_worker_not_found")
|| l.contains("cf_contract_incomplete")
|| l.contains("Known workers")
});
let hint = if cf_hint {
"deploy doctor failed — fix Cloudflare worker name/contract (see above), run `xbp cloudflare doctor --app <name>`, or pass --skip-doctor"
} else {
"deploy doctor failed — fix cluster/config (see above) or pass --skip-doctor"
};
return Err(hint.into());
}
}
let runner = DefaultDeployRunner {
k8s: Arc::clone(&k8s),
promoter,
confirm: Some(confirm),
cloudflare,
oci_build: Some(Arc::new(CliOciBuildAdapter) as Arc<dyn xbp_deploy::OciBuildAdapter>),
};
let mut result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
for line in &result.lines {
println!("{line}");
}
if !request.flags.dry_run {
upload_history_secrets_soft(
&project_root,
&xbp_config,
&plan,
&result,
)
.await;
}
let has_k8s = plan
.services
.iter()
.any(|s| is_kubernetes_provider(&s.provider));
if has_k8s && result.ok && !request.flags.dry_run && !request.flags.skip_rollout {
let verifier = DefaultDeployVerifier {
k8s: Arc::clone(&k8s),
};
match verifier.verify(&ctx, &plan).await {
Ok(report) => {
for line in &report.lines {
if xbp_deploy::k8s_verify_line_is_failure(line) {
println!("{} {line}", "verify".bright_red());
} else {
println!("{line}");
}
}
if !report.ok {
result.ok = false;
result.failed_services.extend(
plan.services
.iter()
.filter(|s| is_kubernetes_provider(&s.provider))
.map(|s| s.name.clone()),
);
result.error = Some(format!(
"post-deploy verify failed: {}",
report.drifts.join("; ")
));
println!(
"{} {}",
"verify failed".bright_red().bold(),
report.drifts.join(" · ")
);
}
}
Err(e) => {
result.ok = false;
result.error = Some(format!("post-deploy verify error: {e}"));
println!("{} {e}", "verify error".bright_red());
}
}
}
if !result.ok && !request.flags.dry_run {
let blob = format!(
"{}\n{}",
result.error.clone().unwrap_or_default(),
result.lines.join("\n")
);
let has_k8s = plan
.services
.iter()
.any(|s| is_kubernetes_provider(&s.provider));
if has_k8s
&& crate::commands::docker_desktop_k8s::offer_deploy_recovery(
&blob,
request.flags.yes,
request.debug,
)
.await
{
println!(
"{}",
"Retrying deploy after Docker Desktop Kubernetes recovery…"
.bright_cyan()
.bold()
);
result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
for line in &result.lines {
println!("{line}");
}
}
}
if request.flags.json {
println!(
"{}",
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
);
}
if !request.flags.dry_run {
let targets: Vec<String> = plan.services.iter().map(|s| s.name.clone()).collect();
let status = if result.ok {
crate::commands::discord_notify::DiscordStatus::Success
} else {
crate::commands::discord_notify::DiscordStatus::Failure
};
let mode = format!("{:?}", request.mode).to_ascii_lowercase();
let detail = if result.ok {
"Deploy completed".to_string()
} else {
result
.error
.clone()
.unwrap_or_else(|| "deploy failed".into())
};
let all_k8s = !plan.services.is_empty()
&& plan
.services
.iter()
.all(|s| is_kubernetes_provider(&s.provider));
let all_cf = !plan.services.is_empty()
&& plan
.services
.iter()
.all(|s| xbp_deploy::is_cloudflare_provider(&s.provider));
let notification = if all_k8s {
crate::commands::discord_notify::kubernetes_deploy_notification(
&targets,
&env,
&mode,
status,
&detail,
)
} else if all_cf {
let has_containers = plan.services.iter().any(|s| {
s.provider.contains("container")
|| s.provider.contains("cloudflare-containers")
});
crate::commands::discord_notify::cloudflare_deploy_notification(
&targets,
&mode,
status,
&format!("{detail} (env: {env})"),
has_containers,
)
} else {
crate::commands::discord_notify::deploy_notification(
&targets.join(", "),
&env,
&mode,
status,
&detail,
)
};
crate::commands::discord_notify::notify_discord_for_project(
&project_root,
Some(&xbp_config),
None,
notification,
)
.await;
}
sync_deploy_activity_to_dashboard(
&project_root,
&xbp_config,
&request,
&env,
&plan,
&result,
)
.await;
if result.ok {
println!("{}", "Result: ok".bright_green().bold());
Ok(())
} else {
println!("{}", "Result: FAILED".bright_red().bold());
Err(result.error.unwrap_or_else(|| "deploy failed".into()))
}
}
DeployMode::History | DeployMode::Status => unreachable!("handled above"),
}
}
async fn sync_deploy_activity_to_dashboard(
project_root: &std::path::Path,
xbp_config: &crate::strategies::XbpConfig,
request: &DeployRequest,
env: &str,
plan: &xbp_deploy::DeployPlan,
result: &xbp_deploy::DeployResult,
) {
use crate::commands::cli_session::{report_ops_deploy_activity, OpsDeployActivityReport};
if request.flags.dry_run {
return;
}
let mode = format!("{:?}", request.mode).to_ascii_lowercase();
let namespace = plan
.services
.first()
.and_then(|s| s.deploy.namespace.clone())
.or_else(|| plan.k8s_plan.default_namespace.clone());
let kube_context = plan.k8s_plan.context.clone();
let summary = if result.ok {
format!(
"Deploy {} → {} service(s) env={}",
plan.target.label(),
plan.services.len(),
env
)
} else {
result
.error
.clone()
.unwrap_or_else(|| "deploy failed".into())
};
report_ops_deploy_activity(OpsDeployActivityReport {
project_root: project_root.to_path_buf(),
project_name: Some(xbp_config.project_name.clone()),
status_ok: result.ok,
mode,
target: plan.target.label(),
env: env.to_string(),
services: plan.services.iter().map(|s| s.name.clone()).collect(),
summary,
logs: Some(result.lines.join("\n")),
error: result.error.clone(),
kube_context,
namespace,
plan_hash: if plan.hash.is_empty() {
None
} else {
Some(plan.hash.clone())
},
})
.await;
}
fn build_oci_resolver(
xbp_config: &crate::strategies::XbpConfig,
_debug: bool,
) -> Option<Arc<dyn xbp_oci::OciResolver>> {
let auth = resolve_auth_from_candidates(
&OciAuthRequest {
registry: "ghcr.io".into(),
..Default::default()
},
&auth_candidates_for_registry("ghcr.io", xbp_config),
);
let client = OciClient::new(auth);
Some(Arc::new(DefaultOciResolver::new(client)) as Arc<dyn xbp_oci::OciResolver>)
}
fn build_oci_promoter(
xbp_config: &crate::strategies::XbpConfig,
) -> Option<Arc<dyn xbp_oci::OciPromoter>> {
let auth = resolve_auth_from_candidates(
&OciAuthRequest {
registry: "ghcr.io".into(),
..Default::default()
},
&auth_candidates_for_registry("ghcr.io", xbp_config),
);
let client = OciClient::new(auth);
Some(Arc::new(DefaultOciPromoter::new(client)) as Arc<dyn xbp_oci::OciPromoter>)
}
fn format_history_status(status: &str) -> String {
match status {
"success" | "ok" => "ok".bright_green().bold().to_string(),
"failed" | "error" => "failed".bright_red().bold().to_string(),
other if other.is_empty() => "—".dimmed().to_string(),
other => other.bright_yellow().to_string(),
}
}
fn truncate_cell(value: &str, max: usize) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return "—".to_string();
}
let count = trimmed.chars().count();
if count <= max {
return trimmed.to_string();
}
let take = max.saturating_sub(1);
format!("{}…", trimmed.chars().take(take).collect::<String>())
}
fn history_detail_for_entry(store: &DeployHistoryStore, entry: &HistoryEntry) -> String {
match store.load_record(entry) {
Ok(Some(record)) => {
if let Some(err) = record.error.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
return truncate_cell(err, 56);
}
if !record.summary.trim().is_empty() {
return truncate_cell(&record.summary, 56);
}
"—".into()
}
_ => "—".into(),
}
}
fn print_deploy_history_table(
store: &DeployHistoryStore,
entries: &[HistoryEntry],
target: &str,
env: &str,
project_name: &str,
stats: &xbp_deploy::DeployHistoryStats,
) -> Result<(), String> {
print_picker_header(
&format!("xbp deploy · history — {project_name}"),
&format!(
"target={} env={} {} recent deploy(s) · ok={} fail={}",
target,
if env.is_empty() { "(any)" } else { env },
entries.len(),
stats.success,
stats.failed,
),
);
ui::divider(72);
if entries.is_empty() {
println!("{}", "No matching deploy history entries.".dimmed());
println!(
"{}",
"Hint: run `xbp deploy <target> --run` to record history under .xbp/deployments/."
.dimmed()
);
return Ok(());
}
let rows: Vec<Vec<String>> = entries
.iter()
.map(|e| {
let code = e
.error_code
.clone()
.or_else(|| {
store.load_record(e).ok().flatten().and_then(|r| {
r.error_code.or_else(|| {
let c = xbp_deploy::classify_deploy_error(
r.error.as_deref(),
&r.summary,
e.status == "success",
);
Some(c.error_code.as_str().into())
})
})
})
.unwrap_or_else(|| "—".into());
vec![
e.timestamp
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
.bright_black()
.to_string(),
format_history_status(&e.status),
e.target.bright_white().bold().to_string(),
code.bright_magenta().to_string(),
e.id.bright_cyan().to_string(),
history_detail_for_entry(store, e).dimmed().to_string(),
]
})
.collect();
print!(
"{}",
render_table(
&["When", "Status", "Target", "Code", "Id", "Detail"],
&rows,
TableStyle::Pipe,
" ",
)
);
ui::divider(72);
if !stats.by_error_code.is_empty() {
println!("{}", "By error code:".bright_black());
let mut codes: Vec<_> = stats.by_error_code.iter().collect();
codes.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
for (code, n) in codes.into_iter().take(12) {
println!(
" {} {}",
format!("{n:>3}").bright_white(),
code.bright_magenta()
);
}
ui::divider(72);
}
println!(
"{} {} · records in {}",
"Total:".bright_black(),
format!("{} entr{}", entries.len(), if entries.len() == 1 { "y" } else { "ies" })
.bright_white(),
store.dir.display().to_string().dimmed()
);
if is_interactive_deploy_tty() && entries.len() > 1 {
println!();
let labels: Vec<String> = entries
.iter()
.map(|e| {
format!(
"{} {} {} {}",
e.timestamp.format("%H:%M:%S"),
e.status,
e.target,
e.id
)
})
.collect();
match select_one("Inspect history entry (Esc to skip)", &labels, 0) {
Ok(Some(idx)) => {
if let Some(entry) = entries.get(idx) {
print_history_entry_detail(store, entry);
}
}
Ok(None) | Err(_) => {}
}
} else if is_interactive_deploy_tty() && entries.len() == 1 {
print_history_entry_detail(store, &entries[0]);
}
Ok(())
}
fn print_deploy_status_table(
store: &DeployHistoryStore,
entry: Option<&HistoryEntry>,
target: &str,
env: &str,
project_name: &str,
) -> Result<(), String> {
print_picker_header(
&format!("xbp deploy · status — {project_name}"),
&format!(
"target={} env={} · latest deploy snapshot",
target,
if env.is_empty() { "(any)" } else { env }
),
);
ui::divider(72);
let Some(entry) = entry else {
println!("{}", "No deploy status recorded.".dimmed());
println!(
"{}",
"Hint: run `xbp deploy <target> --run` to create the first history record.".dimmed()
);
return Ok(());
};
let detail = history_detail_for_entry(store, entry);
let rows = vec![vec![
entry
.timestamp
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
.bright_black()
.to_string(),
format_history_status(&entry.status),
entry.target.bright_white().bold().to_string(),
entry.env.bright_yellow().to_string(),
entry.id.bright_cyan().to_string(),
detail.dimmed().to_string(),
]];
print!(
"{}",
render_table(
&["When", "Status", "Target", "Env", "Id", "Detail"],
&rows,
TableStyle::Pipe,
" ",
)
);
ui::divider(72);
print_history_entry_detail(store, entry);
Ok(())
}
fn print_history_entry_detail(store: &DeployHistoryStore, entry: &HistoryEntry) {
println!();
println!(
"{} {} {} env={}",
"▸".bright_magenta().bold(),
entry.id.bright_cyan(),
format_history_status(&entry.status),
entry.env.bright_yellow()
);
match store.load_record(entry) {
Ok(Some(record)) => {
if let Some(sha) = record.git_sha.as_deref().filter(|s| !s.is_empty()) {
println!(" {} {}", "git:".bright_black(), sha.dimmed());
}
if let Some(ver) = record.project_version.as_deref().filter(|s| !s.is_empty()) {
println!(" {} {}", "version:".bright_black(), ver.bright_white());
}
if let Some(code) = record.error_code.as_deref().filter(|s| !s.is_empty()) {
println!(" {} {}", "code:".bright_black(), code.bright_magenta());
}
if let Some(phase) = record.phase.as_deref().filter(|s| !s.is_empty()) {
println!(" {} {}", "phase:".bright_black(), phase.bright_yellow());
}
if let Some(provider) = record.provider.as_deref().filter(|s| !s.is_empty()) {
println!(
" {} {}",
"provider:".bright_black(),
provider.bright_white()
);
}
if let Some(ns) = record.namespace.as_deref().filter(|s| !s.is_empty()) {
println!(" {} {}", "namespace:".bright_black(), ns.bright_white());
}
if !record.services.is_empty() {
println!(
" {} {}",
"services:".bright_black(),
record.services.join(", ").bright_white()
);
}
if !record.diagnostics.is_empty() {
println!(" {}", "diagnostics:".bright_black());
for d in &record.diagnostics {
println!(" {} {}", "·".bright_black(), d.dimmed());
}
}
if !record.summary.trim().is_empty() {
println!(
" {} {}",
"summary:".bright_black(),
record.summary.trim()
);
}
if let Some(err) = record.error.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
println!(" {} {}", "error:".bright_red().bold(), err.bright_red());
}
println!(
" {} {}",
"path:".bright_black(),
store.dir.join(&entry.path).display().to_string().dimmed()
);
}
Ok(None) => {
println!(
" {} {}",
"path:".bright_black(),
store.dir.join(&entry.path).display().to_string().dimmed()
);
println!(" {}", "full record missing on disk".dimmed());
}
Err(e) => {
println!(" {} {}", "load error:".bright_red(), e);
}
}
}
fn is_interactive_deploy_tty() -> bool {
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& std::env::var_os("XBP_NON_INTERACTIVE").is_none()
}
fn repair_deploy_history_index(
history_dir: &std::path::Path,
json: bool,
) -> Result<(), String> {
let store = DeployHistoryStore::new(history_dir);
let index = store
.rebuild_index_from_records()
.map_err(|e| e.to_string())?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"repaired": true,
"entries": index.entries.len(),
"dir": store.dir.display().to_string(),
}))
.unwrap_or_else(|_| "{}".into())
);
return Ok(());
}
println!(
"{} rebuilt deploy history index ({} entr{})",
"✓".bright_green().bold(),
index.entries.len(),
if index.entries.len() == 1 { "y" } else { "ies" }
);
println!(
" {} {}",
"→".bright_cyan(),
store.index_path().display().to_string().dimmed()
);
for e in index.entries.iter().take(8) {
println!(
" {} {} {} {}",
"·".bright_black(),
e.status,
e.target.bright_white(),
e.id.bright_cyan()
);
}
if index.entries.len() > 8 {
println!(
" {} … and {} more",
"·".bright_black(),
index.entries.len() - 8
);
}
Ok(())
}
async fn upload_history_secrets_soft(
project_root: &std::path::Path,
xbp_config: &crate::strategies::XbpConfig,
plan: &xbp_deploy::DeployPlan,
result: &xbp_deploy::DeployResult,
) {
use crate::commands::cli_session::{
post_deploy_history_secrets, CliDeploySecretItem, CliDeploySecretsPayload,
};
use crate::utils::parse_github_repo_from_remote_url;
let Some(deployment_id) = result.history_id.as_ref() else {
return;
};
if result.history_secrets.is_empty() {
return;
}
let (repository_owner, repository_name) = {
let remote = crate::commands::version::run_git_command(
project_root,
&["remote", "get-url", "origin"],
)
.ok();
remote
.as_deref()
.and_then(parse_github_repo_from_remote_url)
.map(|(o, r)| (Some(o), Some(r)))
.unwrap_or((None, None))
};
let payload = CliDeploySecretsPayload {
deployment_id: deployment_id.clone(),
secrets: result
.history_secrets
.iter()
.map(|s| CliDeploySecretItem {
name: s.name.clone(),
value: s.value.clone(),
hash: Some(s.hash.clone()),
service: s.service.clone(),
})
.collect(),
project_name: Some(xbp_config.project_name.clone()),
project_path: Some(project_root.display().to_string()),
repository_owner,
repository_name,
env: Some(plan.env.clone()),
target: Some(plan.target.label()),
xbp_version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
match post_deploy_history_secrets(&payload).await {
Ok(Some(resp)) => {
println!(
"{} stored {} secret(s) in xbp.app for deployment `{}` (revitalize: xbp deploy --revitalize {})",
"✓".bright_green().bold(),
resp.upserted,
resp.deployment_id,
resp.deployment_id
);
}
Ok(None) => {
println!(
"{} deploy history secrets not uploaded (login with `xbp login` to store in xbp.app D1)",
"·".bright_black()
);
}
Err(e) => {
eprintln!(
"{} could not upload deploy secrets to xbp.app: {e}",
"!".bright_yellow()
);
}
}
}
async fn redact_existing_deploy_history(
project_root: &std::path::Path,
xbp_config: &crate::strategies::XbpConfig,
history_dir: &std::path::Path,
json: bool,
) -> Result<(), String> {
use crate::commands::cli_session::{
post_deploy_history_secrets, CliDeploySecretItem, CliDeploySecretsPayload,
};
use crate::utils::parse_github_repo_from_remote_url;
use xbp_deploy::{
redact_plan_runtime_env, DeployHistoryStore, DEPLOY_SECRET_PLACEHOLDER_PREFIX,
};
let store = DeployHistoryStore::new(history_dir);
let index = store.load_index().map_err(|e| e.to_string())?;
if index.entries.is_empty() {
println!("{}", "No deploy history entries to redact.".dimmed());
return Ok(());
}
let (repository_owner, repository_name) = {
let remote = crate::commands::version::run_git_command(
project_root,
&["remote", "get-url", "origin"],
)
.ok();
remote
.as_deref()
.and_then(parse_github_repo_from_remote_url)
.map(|(o, r)| (Some(o), Some(r)))
.unwrap_or((None, None))
};
let mut files_touched = 0usize;
let mut secrets_uploaded = 0usize;
let mut secrets_total = 0usize;
for entry in &index.entries {
let Some(mut record) = store
.load_record(entry)
.map_err(|e| e.to_string())?
else {
continue;
};
let needs = record.plan.services.iter().any(|s| {
s.runtime_env.iter().any(|(k, v)| {
xbp_deploy::runtime_env_value_should_redact(k, v)
&& !v.starts_with(DEPLOY_SECRET_PLACEHOLDER_PREFIX)
})
});
if !needs && record.runtime_env_redacted == Some(true) {
continue;
}
let secrets_found = redact_plan_runtime_env(&mut record.plan, &record.id);
if secrets_found.is_empty() {
if record.xbp_version.is_none() {
record.xbp_version = Some(env!("CARGO_PKG_VERSION").to_string());
let _ = store.write_record(&record);
}
continue;
}
if record.xbp_version.is_none() {
record.xbp_version = Some(env!("CARGO_PKG_VERSION").to_string());
}
record.runtime_env_redacted = Some(true);
store
.write_record(&record)
.map_err(|e| e.to_string())?;
files_touched += 1;
secrets_total += secrets_found.len();
let secrets: Vec<CliDeploySecretItem> = secrets_found
.iter()
.map(|s| CliDeploySecretItem {
name: s.name.clone(),
value: s.value.clone(),
hash: Some(s.hash.clone()),
service: s.service.clone(),
})
.collect();
let payload = CliDeploySecretsPayload {
deployment_id: record.id.clone(),
secrets,
project_name: Some(xbp_config.project_name.clone()),
project_path: Some(project_root.display().to_string()),
repository_owner: repository_owner.clone(),
repository_name: repository_name.clone(),
env: Some(record.env.clone()),
target: Some(record.target.clone()),
xbp_version: record.xbp_version.clone(),
};
if let Ok(Some(resp)) = post_deploy_history_secrets(&payload).await {
secrets_uploaded += resp.upserted;
}
}
if json {
println!(
"{}",
serde_json::json!({
"filesTouched": files_touched,
"secretsFound": secrets_total,
"secretsUploaded": secrets_uploaded,
})
);
} else {
println!(
"{} redacted {} history file(s); {} secret value(s) scanned; {} uploaded to xbp.app",
"✓".bright_green().bold(),
files_touched,
secrets_total,
secrets_uploaded
);
if secrets_uploaded == 0 && secrets_total > 0 {
println!(
" {} upload skipped — run `xbp login` and ensure apps/web exposes /api/cli/deploy/secrets",
"·".bright_black()
);
}
}
Ok(())
}
async fn revitalize_deploy_history(
history_dir: &std::path::Path,
deployment_id: &str,
out_path: Option<&std::path::Path>,
in_place: bool,
json: bool,
) -> Result<(), String> {
use crate::commands::cli_session::fetch_deploy_history_secrets;
use std::collections::BTreeMap;
use xbp_deploy::{revitalize_record_runtime_env, DeployHistoryStore};
let store = DeployHistoryStore::new(history_dir);
let mut record = store
.load_record_by_id(deployment_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| {
format!(
"deployment history `{}` not found under {}",
deployment_id,
history_dir.display()
)
})?;
let remote = fetch_deploy_history_secrets(&record.id)
.await?
.ok_or_else(|| "not logged in — run `xbp login`".to_string())?;
if remote.secrets.is_empty() {
return Err(format!(
"no secrets in xbp.app for deployment `{}` (was the deploy done while logged in?)",
record.id
));
}
let mut map = BTreeMap::new();
for s in &remote.secrets {
map.insert(s.name.clone(), s.value.clone());
}
let filled = revitalize_record_runtime_env(&mut record, &map);
if filled == 0 {
return Err(
"no placeholders matched D1 secrets (id/hash mismatch or already revitalized)".into(),
);
}
let dest = if let Some(p) = out_path {
p.to_path_buf()
} else if in_place {
store.record_path(&record.id)
} else {
history_dir.join(format!("{}.revitalized.json", record.id))
};
let body = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&dest, body).map_err(|e| e.to_string())?;
if json {
println!(
"{}",
serde_json::json!({
"deploymentId": record.id,
"filled": filled,
"path": dest.display().to_string(),
"xbpVersion": record.xbp_version,
})
);
} else {
println!(
"{} revitalized {} placeholder(s) → {}",
"✓".bright_green().bold(),
filled,
dest.display()
);
if let Some(v) = &record.xbp_version {
println!(" {} xbp {}", "·".bright_black(), v.dimmed());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use xbp_deploy::{resolve_target, DeployGroupView, ProjectConfig, ServiceConfigView};
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn group_preferred_over_service_name_collision_is_engine_level() {
let mut groups = HashMap::new();
groups.insert(
"core".into(),
DeployGroupView {
description: None,
services: vec!["api".into()],
order: vec!["api".into()],
},
);
let config = ProjectConfig {
project_name: "demo".into(),
version: "1.0.0".into(),
project_root: PathBuf::from("."),
services: vec![ServiceConfigView {
name: "api".into(),
root_directory: None,
version: None,
depends_on: vec![],
environment: Default::default(),
port: None,
oci: None,
deploy: Some(xbp_deploy::ServiceDeployView {
provider: "kubernetes".into(),
worker: None,
rollout: None,
destinations: Default::default(),
envs: {
let mut e = HashMap::new();
e.insert(
"production".into(),
xbp_deploy::ServiceDeployEnvView {
namespace: Some("ns".into()),
replicas: None,
health: vec![],
kubernetes: None,
env: Default::default(),
env_files: vec![],
config_files: vec![],
container_port: None,
required_env: vec![],
expose: None,
},
);
e
},
}),
}],
groups,
default_env: Some("production".into()),
kubernetes: None,
history_dir: PathBuf::from(".xbp/deployments"),
lock_file: PathBuf::from(".xbp/deploy-lock.json"),
git_sha: None,
};
let r = resolve_target(&config, "core", "production").unwrap();
assert!(matches!(r.target, DeployTarget::Group(_)));
}
}