Skip to main content

xbp_cli/commands/deploy_engine/
mod.rs

1//! CLI bridge for `xbp deploy`.
2//!
3//! Builds [`xbp_deploy::DeployContext`] from project config and delegates to
4//! the declarative engine (planner / runner / verifier). No business logic here.
5
6pub mod doctor;
7pub mod history_ext;
8pub mod interactive;
9pub mod map_config;
10pub mod oci_build;
11pub mod reset;
12pub mod setup;
13
14pub use interactive::{
15    looks_like_local_kube_context, peel_deploy_mode_positional, resolve_deploy_invocation,
16    DeployInvocationOptions, PeeledDeployArgs, ResolvedDeployInvocation, DEPLOY_MODE_POSITIONALS,
17};
18pub use reset::{run_deploy_reset, DeployResetRequest};
19pub use xbp_deploy::{DeployMode, DeployTarget};
20
21use std::io::IsTerminal;
22use std::path::PathBuf;
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use colored::Colorize;
27use xbp_deploy::{
28    is_kubernetes_provider, unique_service_names, CloudflareDeployAdapter,
29    CloudflareServiceDeployRequest, DefaultDeployPlanner, DefaultDeployRunner,
30    DefaultDeployVerifier, DeployContext, DeployFlags, DeployHistoryStore, DeployPlanner,
31    DeployRunner, DeployVerifier, HistoryEntry,
32};
33
34use self::oci_build::CliOciBuildAdapter;
35use xbp_k8s::KubectlAdapter;
36use xbp_oci::{
37    resolve_auth_from_candidates, DefaultOciPromoter, DefaultOciResolver, OciAuthRequest, OciClient,
38};
39
40use crate::cli::interactive::{print_picker_header, select_one};
41use crate::cli::ui;
42use crate::commands::cloudflare::deploy_app_by_name;
43use crate::commands::service::load_xbp_config_with_root;
44use crate::commands::terminal_table::{render_table, TableStyle};
45use crate::oci::auth_candidates_for_registry;
46
47use self::map_config::map_project_config;
48use self::setup::{ensure_deploy_env_for_target, heal_deploy_groups};
49
50/// CLI bridge: `xbp deploy` CF providers call the same workflow as `xbp cloudflare deploy`.
51struct CliCloudflareDeployAdapter {
52    project_root: PathBuf,
53}
54
55#[async_trait]
56impl CloudflareDeployAdapter for CliCloudflareDeployAdapter {
57    async fn deploy_service(
58        &self,
59        request: CloudflareServiceDeployRequest,
60    ) -> Result<Vec<String>, String> {
61        crate::commands::cloudflare::deploy_app_by_name_with_options(
62            Some(self.project_root.as_path()),
63            &request.worker_app,
64            request.dry_run,
65            request.rollout.as_deref(),
66            request.skip_deploy,
67            request.allow_unchanged_container_image,
68            request.prune_old_images,
69            request.local_build,
70            request.local_build_only,
71        )
72        .await
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct DeployRequest {
78    pub target: String,
79    pub env: Option<String>,
80    pub mode: DeployMode,
81    pub flags: DeployFlags,
82    pub debug: bool,
83    /// When set, restore redacted history secrets instead of deploying.
84    pub revitalize: Option<String>,
85    pub revitalize_out: Option<std::path::PathBuf>,
86    pub revitalize_in_place: bool,
87    /// Retrofit existing history files: redact + upload secrets.
88    pub redact_history: bool,
89    /// Rebuild index.json from attempt records (merge-conflict salvage).
90    pub repair_history: bool,
91    /// Built-in engine health check (repair + stats + optional sibling scan).
92    pub engine_check: bool,
93    /// With engine_check: scan sibling repos for `.xbp/deployments`.
94    pub scan: bool,
95}
96
97/// Entry point for `xbp deploy <target> [flags]`.
98pub async fn run_deploy(request: DeployRequest) -> Result<(), String> {
99    let (project_root, mut xbp_config) = load_xbp_config_with_root().await?;
100
101    // Prune stale group members (scoped npm names, discovery noise) before planning.
102    // Skip for history/engine maintenance — no need to rewrite xbp.toml on every check.
103    let skip_group_heal = request.engine_check
104        || request.repair_history
105        || request.redact_history
106        || request.revitalize.is_some()
107        || matches!(request.mode, DeployMode::History | DeployMode::Status);
108    if !skip_group_heal {
109        let group_heals = heal_deploy_groups(&mut xbp_config);
110        if !group_heals.is_empty() {
111            println!(
112                "{} Auto-healed deploy.groups",
113                "✓".bright_green().bold()
114            );
115            for note in &group_heals {
116                println!("  {} {}", "·".bright_black(), note.dimmed());
117            }
118            if let Some(found) = crate::utils::find_xbp_config_upwards(&project_root) {
119                if let Err(error) =
120                    crate::utils::write_xbp_project_config_at_path(&found.config_path, &xbp_config)
121                {
122                    eprintln!(
123                        "{} could not write healed deploy groups: {error}",
124                        "!".bright_yellow()
125                    );
126                } else {
127                    println!(
128                        "  {} wrote {}",
129                        "→".bright_cyan(),
130                        found.config_path.display().to_string().dimmed()
131                    );
132                }
133            }
134        }
135    }
136
137    let env = request
138        .env
139        .as_deref()
140        .map(str::trim)
141        .filter(|s| !s.is_empty())
142        .map(str::to_string)
143        .or_else(|| {
144            xbp_config
145                .deploy
146                .as_ref()
147                .and_then(|d| d.default_env.clone())
148        })
149        .unwrap_or_else(|| "production".into());
150
151    // History/status/repair/engine-check do not require deploy.envs; skip setup for those modes.
152    let xbp_config = if matches!(
153        request.mode,
154        DeployMode::History | DeployMode::Status
155    ) || request.repair_history
156        || request.engine_check
157        || request.redact_history
158        || request.revitalize.is_some()
159    {
160        xbp_config
161    } else {
162        ensure_deploy_env_for_target(
163            &project_root,
164            xbp_config,
165            &request.target,
166            &env,
167            request.flags.yes,
168        )?
169    };
170
171    let config = map_project_config(&project_root, &xbp_config);
172
173    if request.engine_check {
174        return history_ext::run_engine_check(
175            &project_root,
176            &xbp_config.project_name,
177            &config.history_dir,
178            &env,
179            request.flags.history_limit.max(1),
180            request.scan,
181            request.flags.json,
182        );
183    }
184
185    if request.repair_history {
186        return repair_deploy_history_index(&config.history_dir, request.flags.json);
187    }
188
189    if request.redact_history {
190        return redact_existing_deploy_history(
191            &project_root,
192            &xbp_config,
193            &config.history_dir,
194            request.flags.json,
195        )
196        .await;
197    }
198
199    if let Some(revitalize_id) = request.revitalize.as_deref() {
200        return revitalize_deploy_history(
201            &config.history_dir,
202            revitalize_id,
203            request.revitalize_out.as_deref(),
204            request.revitalize_in_place,
205            request.flags.json,
206        )
207        .await;
208    }
209
210    if matches!(request.mode, DeployMode::History) {
211        let store = DeployHistoryStore::new(&config.history_dir);
212        let limit = request.flags.history_limit.max(1);
213        let entries = store
214            .list(&request.target, &env, limit)
215            .map_err(|e| e.to_string())?;
216        let stats = store
217            .stats(&request.target, &env, limit)
218            .map_err(|e| e.to_string())?;
219        return print_deploy_history_table(
220            &store,
221            &entries,
222            &request.target,
223            &env,
224            &xbp_config.project_name,
225            &stats,
226        );
227    }
228
229    if matches!(request.mode, DeployMode::Status) {
230        let store = DeployHistoryStore::new(&config.history_dir);
231        let entry = store
232            .latest_status(&request.target, &env)
233            .map_err(|e| e.to_string())?;
234        return print_deploy_status_table(
235            &store,
236            entry.as_ref(),
237            &request.target,
238            &env,
239            &xbp_config.project_name,
240        );
241    }
242
243    // Provisional target; planner re-resolves with group>service priority.
244    let target = if request.target.eq_ignore_ascii_case("all") {
245        DeployTarget::All
246    } else if config.groups.contains_key(&request.target) {
247        DeployTarget::Group(request.target.clone())
248    } else {
249        DeployTarget::Service(request.target.clone())
250    };
251
252    let ctx = DeployContext {
253        env: env.clone(),
254        target,
255        config: config.clone(),
256        flags: request.flags.clone(),
257        mode: request.mode,
258    };
259
260    let oci_resolver = build_oci_resolver(&xbp_config, request.debug);
261    let planner = DefaultDeployPlanner::new(oci_resolver.clone());
262    let mut plan = planner.plan(&ctx).await.map_err(|e| e.to_string())?;
263
264    plan = interactive::apply_cli_service_filters(
265        plan,
266        &request.flags.only_services,
267        &request.flags.exclude_services,
268    )?;
269
270    let force_ni = request.flags.json || request.flags.output.is_some();
271    plan = interactive::maybe_edit_plan_services(plan, request.flags.yes, force_ni)?;
272
273    let service_count = unique_service_names(&plan).len();
274    println!(
275        "{} {} → {} service(s) env={}",
276        "Deploy".bright_cyan().bold(),
277        plan.target.label().bright_white().bold(),
278        service_count.to_string().bright_green(),
279        env.bright_yellow()
280    );
281    println!("{}", plan.render_human());
282
283    if let Some(path) = &request.flags.output {
284        if let Some(parent) = path.parent() {
285            let _ = std::fs::create_dir_all(parent);
286        }
287        std::fs::write(
288            path,
289            serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?,
290        )
291        .map_err(|e| format!("write --output: {e}"))?;
292        println!("{} {}", "wrote".bright_cyan(), path.display());
293    }
294
295    interactive::maybe_confirm_multi_service_apply(
296        &plan,
297        request.mode,
298        request.flags.yes,
299        force_ni,
300    )?;
301
302    match request.mode {
303        DeployMode::Plan => {
304            if request.flags.json {
305                println!(
306                    "{}",
307                    serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?
308                );
309            }
310            println!();
311            println!(
312                "{} {}",
313                "OK".bright_green().bold(),
314                "Plan only (no mutations).".bright_white()
315            );
316            println!(
317                "  Re-run with {} to apply (same target/env):\n  {} deploy {} --env {} --run",
318                "--run".bright_green().bold(),
319                "xbp".bright_magenta().bold(),
320                request.target.bright_white(),
321                env.bright_yellow(),
322            );
323            Ok(())
324        }
325        DeployMode::Verify => {
326            let k8s = Arc::new(KubectlAdapter::new(request.debug));
327            let verifier = DefaultDeployVerifier { k8s };
328            let report = verifier.verify(&ctx, &plan).await.map_err(|e| e.to_string())?;
329            for line in &report.lines {
330                println!("{line}");
331            }
332            for drift in &report.drifts {
333                println!("{} {drift}", "drift".bright_red());
334            }
335            if request.flags.json {
336                println!(
337                    "{}",
338                    serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
339                );
340            }
341            if report.ok {
342                println!("{}", "Result: ok".bright_green().bold());
343                Ok(())
344            } else {
345                println!("{}", "Result: FAILED".bright_red().bold());
346                Err(format!("verify failed: {}", report.drifts.join("; ")))
347            }
348        }
349        DeployMode::Run | DeployMode::Promote => {
350            let k8s: Arc<dyn xbp_k8s::K8sAdapter> =
351                Arc::new(KubectlAdapter::new(request.debug));
352            let promoter = build_oci_promoter(&xbp_config);
353            let confirm: Arc<dyn Fn(String) -> bool + Send + Sync> = Arc::new(|msg| {
354                if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
355                    return false;
356                }
357                // Registry-fail recovery: default Yes so operators can keep going locally.
358                let default_yes = msg.contains("local Docker only")
359                    || msg.contains("like --local-image");
360                dialoguer::Confirm::new()
361                    .with_prompt(msg)
362                    .default(default_yes)
363                    .interact()
364                    .unwrap_or(false)
365            });
366            // Cloudflare adapter only used when plan includes CF providers;
367            // k8s path never goes through this adapter.
368            let cloudflare: Option<Arc<dyn xbp_deploy::CloudflareDeployAdapter>> =
369                Some(Arc::new(CliCloudflareDeployAdapter {
370                    project_root: project_root.clone(),
371                }));
372
373            // Repeated same-code failures → nudge operator before another expensive run.
374            if matches!(request.mode, DeployMode::Run) && !request.flags.yes {
375                history_ext::warn_repeated_deploy_failures(
376                    &config.history_dir,
377                    &request.target,
378                    &env,
379                    3,
380                );
381            }
382
383            // Full preflight: cluster recovery, namespaces, CF builds sync, bootstrap plan.
384            if ctx.should_run_doctor() && matches!(request.mode, DeployMode::Run) {
385                let report = doctor::run_deploy_doctor(
386                    &ctx,
387                    &plan,
388                    &xbp_config,
389                    &project_root,
390                    k8s.as_ref(),
391                    request.debug,
392                )
393                .await;
394                for line in &report.lines {
395                    println!("{}", line.bright_black());
396                }
397                if !report.ok {
398                    let cf_hint = report.lines.iter().any(|l| {
399                        l.contains("cf_worker_not_found")
400                            || l.contains("cf_contract_incomplete")
401                            || l.contains("Known workers")
402                    });
403                    let hint = if cf_hint {
404                        "deploy doctor failed — fix Cloudflare worker name/contract (see above), run `xbp cloudflare doctor --app <name>`, or pass --skip-doctor"
405                    } else {
406                        "deploy doctor failed — fix cluster/config (see above) or pass --skip-doctor"
407                    };
408                    return Err(hint.into());
409                }
410            }
411
412            let runner = DefaultDeployRunner {
413                k8s: Arc::clone(&k8s),
414                promoter,
415                confirm: Some(confirm),
416                cloudflare,
417                oci_build: Some(Arc::new(CliOciBuildAdapter) as Arc<dyn xbp_deploy::OciBuildAdapter>),
418            };
419            let mut result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
420            for line in &result.lines {
421                println!("{line}");
422            }
423
424            // Soft-upload redacted runtime_env secrets to xbp.app D1 (never fails deploy).
425            if !request.flags.dry_run {
426                upload_history_secrets_soft(
427                    &project_root,
428                    &xbp_config,
429                    &plan,
430                    &result,
431                )
432                .await;
433            }
434
435            // Cluster truth: after run, verify k8s workloads so a missing Deployment
436            // cannot show Result: ok just because health URL still 200s.
437            let has_k8s = plan
438                .services
439                .iter()
440                .any(|s| is_kubernetes_provider(&s.provider));
441            if has_k8s && result.ok && !request.flags.dry_run && !request.flags.skip_rollout {
442                let verifier = DefaultDeployVerifier {
443                    k8s: Arc::clone(&k8s),
444                };
445                match verifier.verify(&ctx, &plan).await {
446                    Ok(report) => {
447                        for line in &report.lines {
448                            if xbp_deploy::k8s_verify_line_is_failure(line) {
449                                println!("{} {line}", "verify".bright_red());
450                            } else {
451                                println!("{line}");
452                            }
453                        }
454                        if !report.ok {
455                            result.ok = false;
456                            result.failed_services.extend(
457                                plan.services
458                                    .iter()
459                                    .filter(|s| is_kubernetes_provider(&s.provider))
460                                    .map(|s| s.name.clone()),
461                            );
462                            result.error = Some(format!(
463                                "post-deploy verify failed: {}",
464                                report.drifts.join("; ")
465                            ));
466                            println!(
467                                "{} {}",
468                                "verify failed".bright_red().bold(),
469                                report.drifts.join(" · ")
470                            );
471                        }
472                    }
473                    Err(e) => {
474                        result.ok = false;
475                        result.error = Some(format!("post-deploy verify error: {e}"));
476                        println!("{} {e}", "verify error".bright_red());
477                    }
478                }
479            }
480
481            // Interactive Docker Desktop K8s recovery + single deploy retry.
482            if !result.ok && !request.flags.dry_run {
483                let blob = format!(
484                    "{}\n{}",
485                    result.error.clone().unwrap_or_default(),
486                    result.lines.join("\n")
487                );
488                let has_k8s = plan
489                    .services
490                    .iter()
491                    .any(|s| is_kubernetes_provider(&s.provider));
492                if has_k8s
493                    && crate::commands::docker_desktop_k8s::offer_deploy_recovery(
494                        &blob,
495                        request.flags.yes,
496                        request.debug,
497                    )
498                    .await
499                {
500                    println!(
501                        "{}",
502                        "Retrying deploy after Docker Desktop Kubernetes recovery…"
503                            .bright_cyan()
504                            .bold()
505                    );
506                    result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
507                    for line in &result.lines {
508                        println!("{line}");
509                    }
510                }
511            }
512
513            if request.flags.json {
514                println!(
515                    "{}",
516                    serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
517                );
518            }
519            // Skip Discord on dry-run; plan/verify notify elsewhere if needed.
520            if !request.flags.dry_run {
521                let targets: Vec<String> = plan.services.iter().map(|s| s.name.clone()).collect();
522                let status = if result.ok {
523                    crate::commands::discord_notify::DiscordStatus::Success
524                } else {
525                    crate::commands::discord_notify::DiscordStatus::Failure
526                };
527                let mode = format!("{:?}", request.mode).to_ascii_lowercase();
528                let detail = if result.ok {
529                    "Deploy completed".to_string()
530                } else {
531                    result
532                        .error
533                        .clone()
534                        .unwrap_or_else(|| "deploy failed".into())
535                };
536                let all_k8s = !plan.services.is_empty()
537                    && plan
538                        .services
539                        .iter()
540                        .all(|s| is_kubernetes_provider(&s.provider));
541                let all_cf = !plan.services.is_empty()
542                    && plan
543                        .services
544                        .iter()
545                        .all(|s| xbp_deploy::is_cloudflare_provider(&s.provider));
546                let notification = if all_k8s {
547                    crate::commands::discord_notify::kubernetes_deploy_notification(
548                        &targets,
549                        &env,
550                        &mode,
551                        status,
552                        &detail,
553                    )
554                } else if all_cf {
555                    // Workers + Containers (and plain Workers) share workers_deploy toggle.
556                    let has_containers = plan.services.iter().any(|s| {
557                        s.provider.contains("container")
558                            || s.provider.contains("cloudflare-containers")
559                    });
560                    crate::commands::discord_notify::cloudflare_deploy_notification(
561                        &targets,
562                        &mode,
563                        status,
564                        &format!("{detail} (env: {env})"),
565                        has_containers,
566                    )
567                } else {
568                    crate::commands::discord_notify::deploy_notification(
569                        &targets.join(", "),
570                        &env,
571                        &mode,
572                        status,
573                        &detail,
574                    )
575                };
576                crate::commands::discord_notify::notify_discord_for_project(
577                    &project_root,
578                    Some(&xbp_config),
579                    None,
580                    notification,
581                )
582                .await;
583            }
584
585            // Soft-ingest full deploy log + status to xbp.app D1 (dashboard /deploys).
586            sync_deploy_activity_to_dashboard(
587                &project_root,
588                &xbp_config,
589                &request,
590                &env,
591                &plan,
592                &result,
593            )
594            .await;
595
596            if result.ok {
597                println!("{}", "Result: ok".bright_green().bold());
598                Ok(())
599            } else {
600                println!("{}", "Result: FAILED".bright_red().bold());
601                Err(result.error.unwrap_or_else(|| "deploy failed".into()))
602            }
603        }
604        DeployMode::History | DeployMode::Status => unreachable!("handled above"),
605    }
606}
607
608async fn sync_deploy_activity_to_dashboard(
609    project_root: &std::path::Path,
610    xbp_config: &crate::strategies::XbpConfig,
611    request: &DeployRequest,
612    env: &str,
613    plan: &xbp_deploy::DeployPlan,
614    result: &xbp_deploy::DeployResult,
615) {
616    use crate::commands::cli_session::{report_ops_deploy_activity, OpsDeployActivityReport};
617
618    if request.flags.dry_run {
619        return;
620    }
621
622    let mode = format!("{:?}", request.mode).to_ascii_lowercase();
623    let namespace = plan
624        .services
625        .first()
626        .and_then(|s| s.deploy.namespace.clone())
627        .or_else(|| plan.k8s_plan.default_namespace.clone());
628    let kube_context = plan.k8s_plan.context.clone();
629    let summary = if result.ok {
630        format!(
631            "Deploy {} → {} service(s) env={}",
632            plan.target.label(),
633            plan.services.len(),
634            env
635        )
636    } else {
637        result
638            .error
639            .clone()
640            .unwrap_or_else(|| "deploy failed".into())
641    };
642
643    report_ops_deploy_activity(OpsDeployActivityReport {
644        project_root: project_root.to_path_buf(),
645        project_name: Some(xbp_config.project_name.clone()),
646        status_ok: result.ok,
647        mode,
648        target: plan.target.label(),
649        env: env.to_string(),
650        services: plan.services.iter().map(|s| s.name.clone()).collect(),
651        summary,
652        logs: Some(result.lines.join("\n")),
653        error: result.error.clone(),
654        kube_context,
655        namespace,
656        plan_hash: if plan.hash.is_empty() {
657            None
658        } else {
659            Some(plan.hash.clone())
660        },
661    })
662    .await;
663}
664
665fn build_oci_resolver(
666    xbp_config: &crate::strategies::XbpConfig,
667    _debug: bool,
668) -> Option<Arc<dyn xbp_oci::OciResolver>> {
669    // Anonymous/default client; per-image auth can be refined later.
670    let auth = resolve_auth_from_candidates(
671        &OciAuthRequest {
672            registry: "ghcr.io".into(),
673            ..Default::default()
674        },
675        &auth_candidates_for_registry("ghcr.io", xbp_config),
676    );
677    let client = OciClient::new(auth);
678    Some(Arc::new(DefaultOciResolver::new(client)) as Arc<dyn xbp_oci::OciResolver>)
679}
680
681fn build_oci_promoter(
682    xbp_config: &crate::strategies::XbpConfig,
683) -> Option<Arc<dyn xbp_oci::OciPromoter>> {
684    let auth = resolve_auth_from_candidates(
685        &OciAuthRequest {
686            registry: "ghcr.io".into(),
687            ..Default::default()
688        },
689        &auth_candidates_for_registry("ghcr.io", xbp_config),
690    );
691    let client = OciClient::new(auth);
692    Some(Arc::new(DefaultOciPromoter::new(client)) as Arc<dyn xbp_oci::OciPromoter>)
693}
694
695fn format_history_status(status: &str) -> String {
696    match status {
697        "success" | "ok" => "ok".bright_green().bold().to_string(),
698        "failed" | "error" => "failed".bright_red().bold().to_string(),
699        other if other.is_empty() => "—".dimmed().to_string(),
700        other => other.bright_yellow().to_string(),
701    }
702}
703
704fn truncate_cell(value: &str, max: usize) -> String {
705    let trimmed = value.trim();
706    if trimmed.is_empty() {
707        return "—".to_string();
708    }
709    let count = trimmed.chars().count();
710    if count <= max {
711        return trimmed.to_string();
712    }
713    let take = max.saturating_sub(1);
714    format!("{}…", trimmed.chars().take(take).collect::<String>())
715}
716
717fn history_detail_for_entry(store: &DeployHistoryStore, entry: &HistoryEntry) -> String {
718    match store.load_record(entry) {
719        Ok(Some(record)) => {
720            if let Some(err) = record.error.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
721                return truncate_cell(err, 56);
722            }
723            if !record.summary.trim().is_empty() {
724                return truncate_cell(&record.summary, 56);
725            }
726            "—".into()
727        }
728        _ => "—".into(),
729    }
730}
731
732/// Service-style table for `xbp deploy --history` (same visual language as `xbp service`).
733fn print_deploy_history_table(
734    store: &DeployHistoryStore,
735    entries: &[HistoryEntry],
736    target: &str,
737    env: &str,
738    project_name: &str,
739    stats: &xbp_deploy::DeployHistoryStats,
740) -> Result<(), String> {
741    print_picker_header(
742        &format!("xbp deploy  ·  history — {project_name}"),
743        &format!(
744            "target={}  env={}  {} recent deploy(s)  ·  ok={} fail={}",
745            target,
746            if env.is_empty() { "(any)" } else { env },
747            entries.len(),
748            stats.success,
749            stats.failed,
750        ),
751    );
752    ui::divider(72);
753
754    if entries.is_empty() {
755        println!("{}", "No matching deploy history entries.".dimmed());
756        println!(
757            "{}",
758            "Hint: run `xbp deploy <target> --run` to record history under .xbp/deployments/."
759                .dimmed()
760        );
761        return Ok(());
762    }
763
764    let rows: Vec<Vec<String>> = entries
765        .iter()
766        .map(|e| {
767            let code = e
768                .error_code
769                .clone()
770                .or_else(|| {
771                    store.load_record(e).ok().flatten().and_then(|r| {
772                        r.error_code.or_else(|| {
773                            let c = xbp_deploy::classify_deploy_error(
774                                r.error.as_deref(),
775                                &r.summary,
776                                e.status == "success",
777                            );
778                            Some(c.error_code.as_str().into())
779                        })
780                    })
781                })
782                .unwrap_or_else(|| "—".into());
783            vec![
784                e.timestamp
785                    .format("%Y-%m-%d %H:%M:%S UTC")
786                    .to_string()
787                    .bright_black()
788                    .to_string(),
789                format_history_status(&e.status),
790                e.target.bright_white().bold().to_string(),
791                code.bright_magenta().to_string(),
792                e.id.bright_cyan().to_string(),
793                history_detail_for_entry(store, e).dimmed().to_string(),
794            ]
795        })
796        .collect();
797
798    print!(
799        "{}",
800        render_table(
801            &["When", "Status", "Target", "Code", "Id", "Detail"],
802            &rows,
803            TableStyle::Pipe,
804            "  ",
805        )
806    );
807    ui::divider(72);
808    if !stats.by_error_code.is_empty() {
809        println!("{}", "By error code:".bright_black());
810        let mut codes: Vec<_> = stats.by_error_code.iter().collect();
811        codes.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
812        for (code, n) in codes.into_iter().take(12) {
813            println!(
814                "  {}  {}",
815                format!("{n:>3}").bright_white(),
816                code.bright_magenta()
817            );
818        }
819        ui::divider(72);
820    }
821    println!(
822        "{} {}  ·  records in {}",
823        "Total:".bright_black(),
824        format!("{} entr{}", entries.len(), if entries.len() == 1 { "y" } else { "ies" })
825            .bright_white(),
826        store.dir.display().to_string().dimmed()
827    );
828
829    // Interactive: pick a row to expand full error/summary (TTY only).
830    if is_interactive_deploy_tty() && entries.len() > 1 {
831        println!();
832        let labels: Vec<String> = entries
833            .iter()
834            .map(|e| {
835                format!(
836                    "{}  {}  {}  {}",
837                    e.timestamp.format("%H:%M:%S"),
838                    e.status,
839                    e.target,
840                    e.id
841                )
842            })
843            .collect();
844        match select_one("Inspect history entry (Esc to skip)", &labels, 0) {
845            Ok(Some(idx)) => {
846                if let Some(entry) = entries.get(idx) {
847                    print_history_entry_detail(store, entry);
848                }
849            }
850            Ok(None) | Err(_) => {}
851        }
852    } else if is_interactive_deploy_tty() && entries.len() == 1 {
853        print_history_entry_detail(store, &entries[0]);
854    }
855
856    Ok(())
857}
858
859fn print_deploy_status_table(
860    store: &DeployHistoryStore,
861    entry: Option<&HistoryEntry>,
862    target: &str,
863    env: &str,
864    project_name: &str,
865) -> Result<(), String> {
866    print_picker_header(
867        &format!("xbp deploy  ·  status — {project_name}"),
868        &format!(
869            "target={}  env={}  ·  latest deploy snapshot",
870            target,
871            if env.is_empty() { "(any)" } else { env }
872        ),
873    );
874    ui::divider(72);
875
876    let Some(entry) = entry else {
877        println!("{}", "No deploy status recorded.".dimmed());
878        println!(
879            "{}",
880            "Hint: run `xbp deploy <target> --run` to create the first history record.".dimmed()
881        );
882        return Ok(());
883    };
884
885    let detail = history_detail_for_entry(store, entry);
886    let rows = vec![vec![
887        entry
888            .timestamp
889            .format("%Y-%m-%d %H:%M:%S UTC")
890            .to_string()
891            .bright_black()
892            .to_string(),
893        format_history_status(&entry.status),
894        entry.target.bright_white().bold().to_string(),
895        entry.env.bright_yellow().to_string(),
896        entry.id.bright_cyan().to_string(),
897        detail.dimmed().to_string(),
898    ]];
899    print!(
900        "{}",
901        render_table(
902            &["When", "Status", "Target", "Env", "Id", "Detail"],
903            &rows,
904            TableStyle::Pipe,
905            "  ",
906        )
907    );
908    ui::divider(72);
909    print_history_entry_detail(store, entry);
910    Ok(())
911}
912
913fn print_history_entry_detail(store: &DeployHistoryStore, entry: &HistoryEntry) {
914    println!();
915    println!(
916        "{} {}  {}  env={}",
917        "▸".bright_magenta().bold(),
918        entry.id.bright_cyan(),
919        format_history_status(&entry.status),
920        entry.env.bright_yellow()
921    );
922    match store.load_record(entry) {
923        Ok(Some(record)) => {
924            if let Some(sha) = record.git_sha.as_deref().filter(|s| !s.is_empty()) {
925                println!("  {} {}", "git:".bright_black(), sha.dimmed());
926            }
927            if let Some(ver) = record.project_version.as_deref().filter(|s| !s.is_empty()) {
928                println!("  {} {}", "version:".bright_black(), ver.bright_white());
929            }
930            if let Some(code) = record.error_code.as_deref().filter(|s| !s.is_empty()) {
931                println!("  {} {}", "code:".bright_black(), code.bright_magenta());
932            }
933            if let Some(phase) = record.phase.as_deref().filter(|s| !s.is_empty()) {
934                println!("  {} {}", "phase:".bright_black(), phase.bright_yellow());
935            }
936            if let Some(provider) = record.provider.as_deref().filter(|s| !s.is_empty()) {
937                println!(
938                    "  {} {}",
939                    "provider:".bright_black(),
940                    provider.bright_white()
941                );
942            }
943            if let Some(ns) = record.namespace.as_deref().filter(|s| !s.is_empty()) {
944                println!("  {} {}", "namespace:".bright_black(), ns.bright_white());
945            }
946            if !record.services.is_empty() {
947                println!(
948                    "  {} {}",
949                    "services:".bright_black(),
950                    record.services.join(", ").bright_white()
951                );
952            }
953            if !record.diagnostics.is_empty() {
954                println!("  {}", "diagnostics:".bright_black());
955                for d in &record.diagnostics {
956                    println!("    {} {}", "·".bright_black(), d.dimmed());
957                }
958            }
959            if !record.summary.trim().is_empty() {
960                println!(
961                    "  {} {}",
962                    "summary:".bright_black(),
963                    record.summary.trim()
964                );
965            }
966            if let Some(err) = record.error.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
967                println!("  {} {}", "error:".bright_red().bold(), err.bright_red());
968            }
969            println!(
970                "  {} {}",
971                "path:".bright_black(),
972                store.dir.join(&entry.path).display().to_string().dimmed()
973            );
974        }
975        Ok(None) => {
976            println!(
977                "  {} {}",
978                "path:".bright_black(),
979                store.dir.join(&entry.path).display().to_string().dimmed()
980            );
981            println!("  {}", "full record missing on disk".dimmed());
982        }
983        Err(e) => {
984            println!("  {} {}", "load error:".bright_red(), e);
985        }
986    }
987}
988
989fn is_interactive_deploy_tty() -> bool {
990    std::io::stdin().is_terminal()
991        && std::io::stdout().is_terminal()
992        && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
993}
994
995/// Rebuild `.xbp/deployments/index.json` from attempt records on disk.
996fn repair_deploy_history_index(
997    history_dir: &std::path::Path,
998    json: bool,
999) -> Result<(), String> {
1000    let store = DeployHistoryStore::new(history_dir);
1001    let index = store
1002        .rebuild_index_from_records()
1003        .map_err(|e| e.to_string())?;
1004    if json {
1005        println!(
1006            "{}",
1007            serde_json::to_string_pretty(&serde_json::json!({
1008                "repaired": true,
1009                "entries": index.entries.len(),
1010                "dir": store.dir.display().to_string(),
1011            }))
1012            .unwrap_or_else(|_| "{}".into())
1013        );
1014        return Ok(());
1015    }
1016    println!(
1017        "{} rebuilt deploy history index ({} entr{})",
1018        "✓".bright_green().bold(),
1019        index.entries.len(),
1020        if index.entries.len() == 1 { "y" } else { "ies" }
1021    );
1022    println!(
1023        "  {} {}",
1024        "→".bright_cyan(),
1025        store.index_path().display().to_string().dimmed()
1026    );
1027    for e in index.entries.iter().take(8) {
1028        println!(
1029            "  {} {}  {}  {}",
1030            "·".bright_black(),
1031            e.status,
1032            e.target.bright_white(),
1033            e.id.bright_cyan()
1034        );
1035    }
1036    if index.entries.len() > 8 {
1037        println!(
1038            "  {} … and {} more",
1039            "·".bright_black(),
1040            index.entries.len() - 8
1041        );
1042    }
1043    Ok(())
1044}
1045
1046async fn upload_history_secrets_soft(
1047    project_root: &std::path::Path,
1048    xbp_config: &crate::strategies::XbpConfig,
1049    plan: &xbp_deploy::DeployPlan,
1050    result: &xbp_deploy::DeployResult,
1051) {
1052    use crate::commands::cli_session::{
1053        post_deploy_history_secrets, CliDeploySecretItem, CliDeploySecretsPayload,
1054    };
1055    use crate::utils::parse_github_repo_from_remote_url;
1056
1057    let Some(deployment_id) = result.history_id.as_ref() else {
1058        return;
1059    };
1060    if result.history_secrets.is_empty() {
1061        return;
1062    }
1063
1064    let (repository_owner, repository_name) = {
1065        let remote = crate::commands::version::run_git_command(
1066            project_root,
1067            &["remote", "get-url", "origin"],
1068        )
1069        .ok();
1070        remote
1071            .as_deref()
1072            .and_then(parse_github_repo_from_remote_url)
1073            .map(|(o, r)| (Some(o), Some(r)))
1074            .unwrap_or((None, None))
1075    };
1076
1077    let payload = CliDeploySecretsPayload {
1078        deployment_id: deployment_id.clone(),
1079        secrets: result
1080            .history_secrets
1081            .iter()
1082            .map(|s| CliDeploySecretItem {
1083                name: s.name.clone(),
1084                value: s.value.clone(),
1085                hash: Some(s.hash.clone()),
1086                service: s.service.clone(),
1087            })
1088            .collect(),
1089        project_name: Some(xbp_config.project_name.clone()),
1090        project_path: Some(project_root.display().to_string()),
1091        repository_owner,
1092        repository_name,
1093        env: Some(plan.env.clone()),
1094        target: Some(plan.target.label()),
1095        xbp_version: Some(env!("CARGO_PKG_VERSION").to_string()),
1096    };
1097
1098    match post_deploy_history_secrets(&payload).await {
1099        Ok(Some(resp)) => {
1100            println!(
1101                "{} stored {} secret(s) in xbp.app for deployment `{}` (revitalize: xbp deploy --revitalize {})",
1102                "✓".bright_green().bold(),
1103                resp.upserted,
1104                resp.deployment_id,
1105                resp.deployment_id
1106            );
1107        }
1108        Ok(None) => {
1109            println!(
1110                "{} deploy history secrets not uploaded (login with `xbp login` to store in xbp.app D1)",
1111                "·".bright_black()
1112            );
1113        }
1114        Err(e) => {
1115            eprintln!(
1116                "{} could not upload deploy secrets to xbp.app: {e}",
1117                "!".bright_yellow()
1118            );
1119        }
1120    }
1121}
1122
1123async fn redact_existing_deploy_history(
1124    project_root: &std::path::Path,
1125    xbp_config: &crate::strategies::XbpConfig,
1126    history_dir: &std::path::Path,
1127    json: bool,
1128) -> Result<(), String> {
1129    use crate::commands::cli_session::{
1130        post_deploy_history_secrets, CliDeploySecretItem, CliDeploySecretsPayload,
1131    };
1132    use crate::utils::parse_github_repo_from_remote_url;
1133    use xbp_deploy::{
1134        redact_plan_runtime_env, DeployHistoryStore, DEPLOY_SECRET_PLACEHOLDER_PREFIX,
1135    };
1136
1137    let store = DeployHistoryStore::new(history_dir);
1138    let index = store.load_index().map_err(|e| e.to_string())?;
1139    if index.entries.is_empty() {
1140        println!("{}", "No deploy history entries to redact.".dimmed());
1141        return Ok(());
1142    }
1143
1144    let (repository_owner, repository_name) = {
1145        let remote = crate::commands::version::run_git_command(
1146            project_root,
1147            &["remote", "get-url", "origin"],
1148        )
1149        .ok();
1150        remote
1151            .as_deref()
1152            .and_then(parse_github_repo_from_remote_url)
1153            .map(|(o, r)| (Some(o), Some(r)))
1154            .unwrap_or((None, None))
1155    };
1156
1157    let mut files_touched = 0usize;
1158    let mut secrets_uploaded = 0usize;
1159    let mut secrets_total = 0usize;
1160
1161    for entry in &index.entries {
1162        let Some(mut record) = store
1163            .load_record(entry)
1164            .map_err(|e| e.to_string())?
1165        else {
1166            continue;
1167        };
1168
1169        // Skip if already fully placeholder-only for secret-looking keys.
1170        let needs = record.plan.services.iter().any(|s| {
1171            s.runtime_env.iter().any(|(k, v)| {
1172                xbp_deploy::runtime_env_value_should_redact(k, v)
1173                    && !v.starts_with(DEPLOY_SECRET_PLACEHOLDER_PREFIX)
1174            })
1175        });
1176        if !needs && record.runtime_env_redacted == Some(true) {
1177            continue;
1178        }
1179
1180        let secrets_found = redact_plan_runtime_env(&mut record.plan, &record.id);
1181        if secrets_found.is_empty() {
1182            // Still stamp xbp_version on old records when missing.
1183            if record.xbp_version.is_none() {
1184                record.xbp_version = Some(env!("CARGO_PKG_VERSION").to_string());
1185                let _ = store.write_record(&record);
1186            }
1187            continue;
1188        }
1189
1190        if record.xbp_version.is_none() {
1191            record.xbp_version = Some(env!("CARGO_PKG_VERSION").to_string());
1192        }
1193        record.runtime_env_redacted = Some(true);
1194        store
1195            .write_record(&record)
1196            .map_err(|e| e.to_string())?;
1197        files_touched += 1;
1198        secrets_total += secrets_found.len();
1199
1200        let secrets: Vec<CliDeploySecretItem> = secrets_found
1201            .iter()
1202            .map(|s| CliDeploySecretItem {
1203                name: s.name.clone(),
1204                value: s.value.clone(),
1205                hash: Some(s.hash.clone()),
1206                service: s.service.clone(),
1207            })
1208            .collect();
1209        let payload = CliDeploySecretsPayload {
1210            deployment_id: record.id.clone(),
1211            secrets,
1212            project_name: Some(xbp_config.project_name.clone()),
1213            project_path: Some(project_root.display().to_string()),
1214            repository_owner: repository_owner.clone(),
1215            repository_name: repository_name.clone(),
1216            env: Some(record.env.clone()),
1217            target: Some(record.target.clone()),
1218            xbp_version: record.xbp_version.clone(),
1219        };
1220        if let Ok(Some(resp)) = post_deploy_history_secrets(&payload).await {
1221            secrets_uploaded += resp.upserted;
1222        }
1223    }
1224
1225    if json {
1226        println!(
1227            "{}",
1228            serde_json::json!({
1229                "filesTouched": files_touched,
1230                "secretsFound": secrets_total,
1231                "secretsUploaded": secrets_uploaded,
1232            })
1233        );
1234    } else {
1235        println!(
1236            "{} redacted {} history file(s); {} secret value(s) scanned; {} uploaded to xbp.app",
1237            "✓".bright_green().bold(),
1238            files_touched,
1239            secrets_total,
1240            secrets_uploaded
1241        );
1242        if secrets_uploaded == 0 && secrets_total > 0 {
1243            println!(
1244                "  {} upload skipped — run `xbp login` and ensure apps/web exposes /api/cli/deploy/secrets",
1245                "·".bright_black()
1246            );
1247        }
1248    }
1249    Ok(())
1250}
1251
1252async fn revitalize_deploy_history(
1253    history_dir: &std::path::Path,
1254    deployment_id: &str,
1255    out_path: Option<&std::path::Path>,
1256    in_place: bool,
1257    json: bool,
1258) -> Result<(), String> {
1259    use crate::commands::cli_session::fetch_deploy_history_secrets;
1260    use std::collections::BTreeMap;
1261    use xbp_deploy::{revitalize_record_runtime_env, DeployHistoryStore};
1262
1263    let store = DeployHistoryStore::new(history_dir);
1264    let mut record = store
1265        .load_record_by_id(deployment_id)
1266        .map_err(|e| e.to_string())?
1267        .ok_or_else(|| {
1268            format!(
1269                "deployment history `{}` not found under {}",
1270                deployment_id,
1271                history_dir.display()
1272            )
1273        })?;
1274
1275    let remote = fetch_deploy_history_secrets(&record.id)
1276        .await?
1277        .ok_or_else(|| "not logged in — run `xbp login`".to_string())?;
1278
1279    if remote.secrets.is_empty() {
1280        return Err(format!(
1281            "no secrets in xbp.app for deployment `{}` (was the deploy done while logged in?)",
1282            record.id
1283        ));
1284    }
1285
1286    let mut map = BTreeMap::new();
1287    for s in &remote.secrets {
1288        map.insert(s.name.clone(), s.value.clone());
1289    }
1290    let filled = revitalize_record_runtime_env(&mut record, &map);
1291    if filled == 0 {
1292        return Err(
1293            "no placeholders matched D1 secrets (id/hash mismatch or already revitalized)".into(),
1294        );
1295    }
1296
1297    let dest = if let Some(p) = out_path {
1298        p.to_path_buf()
1299    } else if in_place {
1300        store.record_path(&record.id)
1301    } else {
1302        history_dir.join(format!("{}.revitalized.json", record.id))
1303    };
1304
1305    let body = serde_json::to_string_pretty(&record).map_err(|e| e.to_string())?;
1306    if let Some(parent) = dest.parent() {
1307        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1308    }
1309    std::fs::write(&dest, body).map_err(|e| e.to_string())?;
1310
1311    if json {
1312        println!(
1313            "{}",
1314            serde_json::json!({
1315                "deploymentId": record.id,
1316                "filled": filled,
1317                "path": dest.display().to_string(),
1318                "xbpVersion": record.xbp_version,
1319            })
1320        );
1321    } else {
1322        println!(
1323            "{} revitalized {} placeholder(s) → {}",
1324            "✓".bright_green().bold(),
1325            filled,
1326            dest.display()
1327        );
1328        if let Some(v) = &record.xbp_version {
1329            println!("  {} xbp {}", "·".bright_black(), v.dimmed());
1330        }
1331    }
1332    Ok(())
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337    use super::*;
1338    use xbp_deploy::{resolve_target, DeployGroupView, ProjectConfig, ServiceConfigView};
1339    use std::collections::HashMap;
1340    use std::path::PathBuf;
1341
1342    #[test]
1343    fn group_preferred_over_service_name_collision_is_engine_level() {
1344        // Smoke: engine target resolver is available to CLI via crate.
1345        let mut groups = HashMap::new();
1346        groups.insert(
1347            "core".into(),
1348            DeployGroupView {
1349                description: None,
1350                services: vec!["api".into()],
1351                order: vec!["api".into()],
1352            },
1353        );
1354        let config = ProjectConfig {
1355            project_name: "demo".into(),
1356            version: "1.0.0".into(),
1357            project_root: PathBuf::from("."),
1358            services: vec![ServiceConfigView {
1359                name: "api".into(),
1360                root_directory: None,
1361                version: None,
1362                depends_on: vec![],
1363                environment: Default::default(),
1364                port: None,
1365                oci: None,
1366                deploy: Some(xbp_deploy::ServiceDeployView {
1367                    provider: "kubernetes".into(),
1368                    worker: None,
1369                    rollout: None,
1370                    destinations: Default::default(),
1371                    envs: {
1372                        let mut e = HashMap::new();
1373                        e.insert(
1374                            "production".into(),
1375                            xbp_deploy::ServiceDeployEnvView {
1376                                namespace: Some("ns".into()),
1377                                replicas: None,
1378                                health: vec![],
1379                                kubernetes: None,
1380                                env: Default::default(),
1381                                env_files: vec![],
1382                                config_files: vec![],
1383                                container_port: None,
1384                                required_env: vec![],
1385                                expose: None,
1386                            },
1387                        );
1388                        e
1389                    },
1390                }),
1391            }],
1392            groups,
1393            default_env: Some("production".into()),
1394            kubernetes: None,
1395            history_dir: PathBuf::from(".xbp/deployments"),
1396            lock_file: PathBuf::from(".xbp/deploy-lock.json"),
1397            git_sha: None,
1398        };
1399        let r = resolve_target(&config, "core", "production").unwrap();
1400        assert!(matches!(r.target, DeployTarget::Group(_)));
1401    }
1402}