Skip to main content

xbp_cli/commands/deploy_engine/
interactive.rs

1//! Interactive deploy target / mode / env resolution.
2//!
3//! Makes `xbp deploy` (and `xbp deploy --plan`) usable without memorizing
4//! service names — pick from services[], deploy.groups, or `all`.
5//!
6//! Also peels reserved positional mode words (`plan`, `run`, `verify`, …)
7//! so `xbp deploy plan` is never treated as target=`plan`.
8
9use std::collections::BTreeSet;
10use std::io::IsTerminal;
11
12use colored::Colorize;
13use dialoguer::Input;
14use xbp_deploy::DeployMode;
15
16use crate::cli::interactive::{
17    confirm, pad_label, print_picker_header, searchable_multi_select, searchable_select_required,
18    select_one, xbp_theme,
19};
20use crate::strategies::XbpConfig;
21use xbp_deploy::{
22    filter_deploy_plan, retain_services_in_plan, service_plan_labels, unique_service_names,
23    DeployPlan,
24};
25use std::collections::HashSet;
26
27#[derive(Debug, Clone)]
28pub struct ResolvedDeployInvocation {
29    pub target: String,
30    pub env: Option<String>,
31    pub mode: DeployMode,
32    /// OCI promotion tag when mode is Promote (from `--promote` or interactive prompt).
33    pub promote_tag: Option<String>,
34    /// Build into local Docker only (no registry push).
35    pub local_image: bool,
36    pub skip_build: bool,
37    pub dry_run: bool,
38    pub skip_doctor: bool,
39    pub skip_health: bool,
40    pub skip_rollout: bool,
41    pub skip_apply: bool,
42    pub skip_oci: bool,
43    /// Skip post-rollout port-forward / hosts DNS.
44    pub skip_expose: bool,
45}
46
47/// CLI flag seeds for interactive deploy resolution (TTY can override).
48#[derive(Debug, Clone, Default)]
49pub struct DeployInvocationOptions {
50    pub promote_tag: Option<String>,
51    pub local_image: bool,
52    pub push_image: bool,
53    pub skip_build: bool,
54    pub dry_run: bool,
55    pub skip_doctor: bool,
56    pub skip_health: bool,
57    pub skip_rollout: bool,
58    pub skip_apply: bool,
59    pub skip_oci: bool,
60    pub skip_expose: bool,
61    /// Explicit `--context` (used for auto local-image default).
62    pub kube_context: Option<String>,
63}
64
65/// How images / Worker artifacts are produced before apply.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum ImageBuildStrategy {
68    /// Kubernetes: docker load local. Cloudflare: pre-build container with local Docker, then Wrangler.
69    LocalLoad,
70    /// Kubernetes: docker build + registry push. Cloudflare: Wrangler/OpenNext owns build (no GHCR).
71    PushRegistry,
72    /// Reuse existing image / config-only CF re-run.
73    SkipBuild,
74}
75
76/// Destinations for the resolved deploy target (drives wizard copy + defaults).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum TargetDestProfile {
79    CloudflareOnly,
80    KubernetesOnly,
81    Mixed,
82    Unknown,
83}
84
85impl TargetDestProfile {
86    fn has_cloudflare(self) -> bool {
87        matches!(self, Self::CloudflareOnly | Self::Mixed)
88    }
89
90    fn has_kubernetes(self) -> bool {
91        matches!(self, Self::KubernetesOnly | Self::Mixed)
92    }
93
94    fn is_cloudflare_only(self) -> bool {
95        matches!(self, Self::CloudflareOnly)
96    }
97}
98
99/// Mode words accepted as a bare positional (instead of `--plan` / `--run` / …).
100pub const DEPLOY_MODE_POSITIONALS: &[&str] = &["plan", "run", "verify", "status", "history"];
101
102/// Result of peeling a reserved mode word from the positional target.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct PeeledDeployArgs {
105    /// Remaining service/group target (None when the positional was only a mode word).
106    pub target: Option<String>,
107    pub mode: DeployMode,
108    /// True when mode came from a CLI flag or a reserved positional.
109    pub mode_explicit: bool,
110}
111
112/// Interpret `plan`/`run`/… as modes when used as the positional TARGET.
113///
114/// Examples:
115/// - `xbp deploy plan` → mode=Plan, no target
116/// - `xbp deploy run` → mode=Run, no target  
117/// - `xbp deploy plan --run` → error (conflicting modes)
118/// - `xbp deploy athena --plan` → unchanged (athena stays target)
119pub fn peel_deploy_mode_positional(
120    target: Option<String>,
121    mode: DeployMode,
122    mode_explicit: bool,
123) -> Result<PeeledDeployArgs, String> {
124    let Some(raw) = target
125        .as_deref()
126        .map(str::trim)
127        .filter(|s| !s.is_empty())
128        .map(str::to_string)
129    else {
130        return Ok(PeeledDeployArgs {
131            target: None,
132            mode,
133            mode_explicit,
134        });
135    };
136
137    let lower = raw.to_ascii_lowercase();
138    let positional_mode = match lower.as_str() {
139        "plan" => Some(DeployMode::Plan),
140        "run" => Some(DeployMode::Run),
141        "verify" => Some(DeployMode::Verify),
142        "status" => Some(DeployMode::Status),
143        "history" => Some(DeployMode::History),
144        _ => None,
145    };
146
147    let Some(from_positional) = positional_mode else {
148        return Ok(PeeledDeployArgs {
149            target: Some(raw),
150            mode,
151            mode_explicit,
152        });
153    };
154
155    if mode_explicit {
156        let flag_label = mode_flag_label(mode);
157        let pos_label = mode_flag_label(from_positional);
158        if std::mem::discriminant(&mode) != std::mem::discriminant(&from_positional) {
159            return Err(format!(
160                "`{raw}` is a deploy *mode*, not a service name — and it conflicts with `{flag_label}`.\n\n\
161                 Plan only (no mutations):\n  xbp deploy --plan\n  xbp deploy <service> --plan\n\n\
162                 Apply after reviewing the plan:\n  xbp deploy --run\n  xbp deploy <service> --run\n\n\
163                 Do not combine `{pos_label}` with `{flag_label}`."
164            ));
165        }
166        // Same mode twice (`xbp deploy plan --plan`) — fine, drop positional.
167        return Ok(PeeledDeployArgs {
168            target: None,
169            mode,
170            mode_explicit: true,
171        });
172    }
173
174    Ok(PeeledDeployArgs {
175        target: None,
176        mode: from_positional,
177        mode_explicit: true,
178    })
179}
180
181fn mode_flag_label(mode: DeployMode) -> &'static str {
182    match mode {
183        DeployMode::Plan => "--plan",
184        DeployMode::Run => "--run",
185        DeployMode::Verify => "--verify",
186        DeployMode::Status => "--status",
187        DeployMode::History => "--history",
188        DeployMode::Promote => "--promote",
189    }
190}
191
192/// Resolve missing target / mode / env / OCI options via TTY prompts when possible.
193pub fn resolve_deploy_invocation(
194    config: &XbpConfig,
195    target: Option<String>,
196    env: Option<String>,
197    mode: DeployMode,
198    mode_explicit: bool,
199    yes: bool,
200    force_non_interactive: bool,
201    options: DeployInvocationOptions,
202) -> Result<ResolvedDeployInvocation, String> {
203    let interactive = is_interactive() && !force_non_interactive && !yes;
204    let target_raw = target
205        .as_deref()
206        .map(str::trim)
207        .filter(|s| !s.is_empty())
208        .map(str::to_string);
209
210    let choices = deploy_target_choices(config);
211    if choices.is_empty() {
212        return Err(
213            "No services[] defined in project config. Run `xbp init` or `xbp version discover` first."
214                .into(),
215        );
216    }
217
218    let resolved_target = match target_raw {
219        Some(t) => t,
220        None if interactive => prompt_target(config, &choices)?,
221        None if choices.len() == 1 => {
222            let only = choices[0].value.clone();
223            println!(
224                "{} using sole deploy target `{}`",
225                "deploy".bright_cyan().bold(),
226                only.bright_white()
227            );
228            only
229        }
230        None => {
231            return Err(format!(
232                "deploy target is required.\n\nAvailable targets:\n{}\n\n\
233                 Examples:\n  xbp deploy {}\n  xbp deploy --plan\n  xbp deploy all --env production --plan\n\n\
234                 In a TTY, omit <TARGET> to pick interactively.",
235                format_target_help(&choices),
236                choices[0].value
237            ));
238        }
239    };
240
241    let resolved_mode = if mode_explicit || !interactive {
242        mode
243    } else {
244        prompt_mode(mode)?
245    };
246
247    let resolved_env = match env
248        .as_deref()
249        .map(str::trim)
250        .filter(|s| !s.is_empty())
251        .map(str::to_string)
252    {
253        Some(e) => Some(e),
254        None if interactive => prompt_env(config, &resolved_target)?,
255        None => None, // engine / default_env
256    };
257
258    let promote_tag = if matches!(resolved_mode, DeployMode::Promote) {
259        match options
260            .promote_tag
261            .as_deref()
262            .map(str::trim)
263            .filter(|s| !s.is_empty())
264            .map(str::to_string)
265        {
266            Some(tag) => Some(tag),
267            None if interactive => Some(prompt_promote_tag(config, &resolved_target, &resolved_env)?),
268            None if yes => {
269                return Err(
270                    "promote mode requires a tag. Pass `--promote <TAG>` (e.g. `--promote stable`) or run interactively."
271                        .into(),
272                );
273            }
274            None => {
275                return Err(
276                    "promote mode requires `--promote <TAG>`.\n\nExample:\n  xbp deploy athena --promote stable --dry-run\n\nIn a TTY, choose promote mode and enter the tag when prompted."
277                        .into(),
278                );
279            }
280        }
281    } else {
282        None
283    };
284
285    // Build / apply options — full TUI when interactive; CLI/auto otherwise.
286    // Profile drives CF-vs-k8s wording so Worker targets are not asked k8s OCI questions.
287    let dest_profile = target_dest_profile(config, &resolved_target);
288    let needs_image_options = matches!(
289        resolved_mode,
290        DeployMode::Plan | DeployMode::Run | DeployMode::Promote | DeployMode::Verify
291    );
292    let (
293        local_image,
294        skip_build,
295        dry_run,
296        skip_doctor,
297        skip_health,
298        skip_rollout,
299        skip_apply,
300        skip_oci,
301        skip_expose,
302    ) = if interactive && needs_image_options {
303        prompt_deploy_build_and_apply_options(
304            config,
305            &resolved_target,
306            &resolved_mode,
307            &options,
308            dest_profile,
309        )?
310    } else {
311        resolve_image_options_noninteractive(&options, dest_profile)
312    };
313
314    if interactive {
315        match resolved_mode {
316            DeployMode::Plan => {
317                let image_note = image_strategy_note(dest_profile, local_image, skip_build);
318                println!(
319                    "  {} plan only (no mutations) for `{}`{} · {} — re-run with {} to apply",
320                    "·".bright_cyan(),
321                    resolved_target.bright_white(),
322                    resolved_env
323                        .as_deref()
324                        .map(|e| format!(" (env: {e})"))
325                        .unwrap_or_default()
326                        .bright_black(),
327                    image_note.bright_yellow(),
328                    "--run".bright_green().bold()
329                );
330            }
331            DeployMode::Run | DeployMode::Promote => {
332                let label = match resolved_mode {
333                    DeployMode::Promote => {
334                        format!(
335                            "promote → :{}",
336                            promote_tag.as_deref().unwrap_or("?")
337                        )
338                    }
339                    _ => {
340                        if dest_profile.is_cloudflare_only() {
341                            "run (Cloudflare Worker deploy)".to_string()
342                        } else {
343                            "run (apply)".to_string()
344                        }
345                    }
346                };
347                let image_note = image_strategy_note(dest_profile, local_image, skip_build);
348                let mut extras = Vec::new();
349                if dry_run {
350                    extras.push("dry-run");
351                }
352                if skip_doctor {
353                    extras.push("skip-doctor");
354                }
355                if skip_health {
356                    extras.push("skip-health");
357                }
358                if skip_rollout {
359                    extras.push("skip-rollout");
360                }
361                if skip_expose && dest_profile.has_kubernetes() {
362                    extras.push("skip-expose");
363                }
364                if skip_apply {
365                    if dest_profile.is_cloudflare_only() && local_image {
366                        extras.push("local-build-only (no wrangler deploy)");
367                    } else {
368                        extras.push("skip-apply");
369                    }
370                }
371                let extras_s = if extras.is_empty() {
372                    String::new()
373                } else {
374                    format!(" · {}", extras.join(", "))
375                };
376                let ok = confirm(
377                    &format!(
378                        "Apply deploy `{label}` for target `{}`{} · {}{}?",
379                        resolved_target,
380                        resolved_env
381                            .as_deref()
382                            .map(|e| format!(" (env: {e})"))
383                            .unwrap_or_default(),
384                        image_note,
385                        extras_s
386                    ),
387                    matches!(resolved_mode, DeployMode::Promote),
388                )?;
389                if !ok {
390                    return Err("deploy aborted by user".into());
391                }
392            }
393            _ => {}
394        }
395    } else if local_image && !skip_build && !options.skip_oci && dest_profile.has_kubernetes() {
396        // Non-interactive auto-local: still surface the choice (json/CI may skip).
397        if !force_non_interactive {
398            println!(
399                "{} OCI images: local Docker load only (no registry push). Use {} or the interactive picker to push.",
400                "i".bright_blue().bold(),
401                "--push-image".bright_yellow()
402            );
403        }
404    }
405
406    Ok(ResolvedDeployInvocation {
407        target: resolved_target,
408        env: resolved_env,
409        mode: resolved_mode,
410        promote_tag,
411        local_image,
412        skip_build,
413        dry_run,
414        skip_doctor,
415        skip_health,
416        skip_rollout,
417        skip_apply,
418        skip_oci,
419        skip_expose,
420    })
421}
422
423type DeployOptionBundle = (bool, bool, bool, bool, bool, bool, bool, bool, bool);
424
425fn image_strategy_note(profile: TargetDestProfile, local_image: bool, skip_build: bool) -> &'static str {
426    if skip_build {
427        return if profile.is_cloudflare_only() {
428            "reuse existing CF image / config-only"
429        } else {
430            "skip image build"
431        };
432    }
433    if profile.is_cloudflare_only() {
434        return if local_image {
435            "local Docker container pre-build + Wrangler"
436        } else {
437            "OpenNext / Wrangler build + deploy"
438        };
439    }
440    if local_image {
441        "local Docker load"
442    } else {
443        "registry push"
444    }
445}
446
447fn resolve_image_options_noninteractive(
448    options: &DeployInvocationOptions,
449    profile: TargetDestProfile,
450) -> DeployOptionBundle {
451    let local_image = if options.push_image {
452        false
453    } else if options.local_image {
454        true
455    } else if profile.is_cloudflare_only() {
456        // Never infer CF local-build from kube context (docker-desktop is unrelated).
457        false
458    } else {
459        looks_like_local_kube_context(options.kube_context.as_deref())
460    };
461    (
462        local_image,
463        options.skip_build,
464        options.dry_run,
465        options.skip_doctor,
466        options.skip_health,
467        options.skip_rollout,
468        options.skip_apply,
469        options.skip_oci,
470        options.skip_expose,
471    )
472}
473
474/// Interactive: build strategy + optional apply toggles (CF-aware when target is Workers-only).
475fn prompt_deploy_build_and_apply_options(
476    config: &XbpConfig,
477    target: &str,
478    mode: &DeployMode,
479    options: &DeployInvocationOptions,
480    profile: TargetDestProfile,
481) -> Result<DeployOptionBundle, String> {
482    let has_dockerfile = target_has_dockerfile(config, target);
483    // Only auto-prefer local Docker load for k8s (or mixed) local clusters.
484    let local_default = profile.has_kubernetes()
485        && looks_like_local_kube_context(options.kube_context.as_deref());
486
487    // --- Image / Worker build strategy ---
488    let strategy = if options.skip_build {
489        ImageBuildStrategy::SkipBuild
490    } else if options.push_image {
491        ImageBuildStrategy::PushRegistry
492    } else if options.local_image {
493        ImageBuildStrategy::LocalLoad
494    } else if profile.is_cloudflare_only() {
495        // CF Workers/OpenNext always "build" via Wrangler path unless user opts into
496        // local container pre-build or explicit skip. Dockerfile alone does not imply GHCR.
497        if matches!(mode, DeployMode::History | DeployMode::Status) {
498            ImageBuildStrategy::SkipBuild
499        } else {
500            prompt_cloudflare_build_strategy(has_dockerfile)?
501        }
502    } else if !has_dockerfile {
503        ImageBuildStrategy::SkipBuild
504    } else if matches!(mode, DeployMode::History | DeployMode::Status) {
505        ImageBuildStrategy::SkipBuild
506    } else {
507        prompt_image_build_strategy(local_default, has_dockerfile, profile)?
508    };
509
510    let (local_image, skip_build) = match strategy {
511        ImageBuildStrategy::LocalLoad => (true, false),
512        ImageBuildStrategy::PushRegistry => (false, false),
513        ImageBuildStrategy::SkipBuild => (false, true),
514    };
515
516    // Plan / verify: image choice only (no apply toggles).
517    if matches!(mode, DeployMode::Plan | DeployMode::Verify) {
518        return Ok((
519            local_image,
520            skip_build,
521            options.dry_run,
522            options.skip_doctor,
523            options.skip_health,
524            options.skip_rollout,
525            options.skip_apply,
526            options.skip_oci,
527            options.skip_expose,
528        ));
529    }
530
531    if matches!(mode, DeployMode::History | DeployMode::Status) {
532        return Ok(resolve_image_options_noninteractive(options, profile));
533    }
534
535    // --- Apply options multi-select ---
536    let apply = prompt_apply_options(options, profile)?;
537
538    Ok((
539        local_image,
540        skip_build,
541        apply.dry_run,
542        apply.skip_doctor,
543        apply.skip_health,
544        apply.skip_rollout,
545        apply.skip_apply,
546        apply.skip_oci,
547        apply.skip_expose,
548    ))
549}
550
551#[derive(Debug, Clone, Copy)]
552struct ApplyOptionToggles {
553    dry_run: bool,
554    skip_doctor: bool,
555    skip_health: bool,
556    skip_rollout: bool,
557    skip_apply: bool,
558    skip_oci: bool,
559    skip_expose: bool,
560}
561
562/// Cloudflare-only: build path is Worker/OpenNext/containers — not GHCR/k8s.
563fn prompt_cloudflare_build_strategy(has_dockerfile: bool) -> Result<ImageBuildStrategy, String> {
564    print_picker_header(
565        "xbp deploy  ·  Cloudflare build",
566        if has_dockerfile {
567            "Worker + Container: OpenNext/Wrangler deploys by default; optional local Docker pre-build"
568        } else {
569            "Cloudflare Worker: OpenNext install/build + wrangler deploy (no cluster / GHCR)"
570        },
571    );
572
573    let options = [
574        (
575            ImageBuildStrategy::PushRegistry, // reuse flag: means "remote CF build path"
576            "full CF deploy   ·  OpenNext/Wrangler build + deploy Worker (and containers if configured)",
577        ),
578        (
579            ImageBuildStrategy::LocalLoad,
580            "local container  ·  docker build container image locally, then Wrangler deploy/push",
581        ),
582        (
583            ImageBuildStrategy::SkipBuild,
584            "config-only      ·  skip local pre-build; allow unchanged container digest if present",
585        ),
586    ];
587    let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
588    // Default: full CF deploy (not local kube "load").
589    let idx = select_one("Cloudflare build strategy", &labels, 0)?
590        .ok_or_else(|| "Selection cancelled".to_string())?;
591    Ok(options[idx].0)
592}
593
594fn prompt_image_build_strategy(
595    local_default: bool,
596    has_dockerfile: bool,
597    profile: TargetDestProfile,
598) -> Result<ImageBuildStrategy, String> {
599    let subtitle = if profile == TargetDestProfile::Mixed {
600        "Mixed k8s + Cloudflare target — OCI options apply to cluster half; CF still builds via Wrangler"
601    } else if local_default {
602        "Local kube context detected — default is load into Docker (no ghcr push)"
603    } else if has_dockerfile {
604        "Services declare oci.dockerfile — choose build + load or push to registry"
605    } else {
606        "No oci.dockerfile on selected services — skip build uses existing images"
607    };
608    print_picker_header("xbp deploy  ·  OCI images", subtitle);
609
610    let options = [
611        (
612            ImageBuildStrategy::LocalLoad,
613            "local load     ·  docker build into local engine (no registry push)  ← docker-desktop / kind",
614        ),
615        (
616            ImageBuildStrategy::PushRegistry,
617            "registry push  ·  docker build + push (needs docker login for ghcr/…)",
618        ),
619        (
620            ImageBuildStrategy::SkipBuild,
621            "skip build     ·  use existing image ref (no docker build)",
622        ),
623    ];
624    let default = if !has_dockerfile {
625        2
626    } else if local_default {
627        0
628    } else {
629        1
630    };
631    let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
632    let idx = select_one("Image build strategy", &labels, default)?
633        .ok_or_else(|| "Selection cancelled".to_string())?;
634    Ok(options[idx].0)
635}
636
637fn prompt_apply_options(
638    seed: &DeployInvocationOptions,
639    profile: TargetDestProfile,
640) -> Result<ApplyOptionToggles, String> {
641    let header = if profile.is_cloudflare_only() {
642        "Space toggles  ·  Enter confirms  ·  leave all off for a normal Worker deploy"
643    } else {
644        "Space toggles  ·  Enter confirms  ·  leave all off for a normal apply"
645    };
646    print_picker_header("xbp deploy  ·  apply options", header);
647
648    // Build destination-aware labels (same indices for mapping).
649    let skip_apply_label = if profile.is_cloudflare_only() {
650        "skip apply        ·  skip Wrangler/Worker deploy (with local container strategy → build-only)"
651    } else if profile == TargetDestProfile::Mixed {
652        "skip apply        ·  skip kubectl + Wrangler apply (still may build)"
653    } else {
654        "skip apply        ·  skip kubectl apply (still may build/verify)"
655    };
656    let skip_doctor_label = if profile.is_cloudflare_only() {
657        "skip doctor       ·  skip Cloudflare preflight (Wrangler/account)"
658    } else if profile.has_cloudflare() {
659        "skip doctor       ·  skip cluster + Cloudflare preflight"
660    } else {
661        "skip doctor       ·  skip cluster preflight"
662    };
663    let dry_run_label = if profile.is_cloudflare_only() {
664        "dry-run           ·  print CF actions only (no Wrangler mutations)"
665    } else {
666        "dry-run           ·  print actions only (no registry/cluster mutations)"
667    };
668
669    let mut items: Vec<(&str, bool)> = vec![
670        (dry_run_label, seed.dry_run),
671        (skip_doctor_label, seed.skip_doctor),
672        (
673            "skip health       ·  skip HTTP health probes after rollout",
674            seed.skip_health,
675        ),
676        (
677            "skip rollout wait ·  do not wait for workload / container ready",
678            seed.skip_rollout,
679        ),
680        (skip_apply_label, seed.skip_apply),
681    ];
682    if profile.has_kubernetes() {
683        items.push((
684            "skip OCI resolve  ·  skip registry digest lookups",
685            seed.skip_oci,
686        ));
687        items.push((
688            "skip expose       ·  skip port-forward / hosts DNS after rollout",
689            seed.skip_expose,
690        ));
691    }
692
693    let labels: Vec<&str> = items.iter().map(|(l, _)| *l).collect();
694    let defaults: Vec<bool> = items.iter().map(|(_, d)| *d).collect();
695    let selected = searchable_multi_select("Apply toggles", &labels, &defaults)?;
696
697    let on = |i: usize| selected.contains(&i);
698    // Indices 0..4 always present; 5/6 only when k8s is in the plan.
699    Ok(ApplyOptionToggles {
700        dry_run: on(0),
701        skip_doctor: on(1),
702        skip_health: on(2),
703        skip_rollout: on(3),
704        skip_apply: on(4),
705        skip_oci: profile.has_kubernetes() && on(5),
706        skip_expose: profile.has_kubernetes() && on(6),
707    })
708}
709
710/// Classify destinations for services in `target` (group / single / all).
711fn target_dest_profile(config: &XbpConfig, target: &str) -> TargetDestProfile {
712    let names = services_for_target(config, target);
713    let services = config.services.as_deref().unwrap_or(&[]);
714    let selected: Vec<&crate::strategies::ServiceConfig> = if names.is_empty() {
715        services.iter().collect()
716    } else {
717        services
718            .iter()
719            .filter(|s| names.iter().any(|n| n == &s.name))
720            .collect()
721    };
722    if selected.is_empty() {
723        return TargetDestProfile::Unknown;
724    }
725
726    let mut any_cf = false;
727    let mut any_k8s = false;
728    for svc in selected {
729        let Some(deploy) = svc.deploy.as_ref() else {
730            continue;
731        };
732        if !deploy.destinations.is_empty() {
733            for (name, dest) in &deploy.destinations {
734                if dest.enabled == Some(false) {
735                    continue;
736                }
737                let provider = dest
738                    .provider
739                    .as_deref()
740                    .unwrap_or(name.as_str())
741                    .to_ascii_lowercase();
742                let key = name.to_ascii_lowercase();
743                if provider.contains("cloudflare")
744                    || provider.contains("worker")
745                    || key.contains("cloudflare")
746                    || key == "worker"
747                    || key == "cf"
748                {
749                    any_cf = true;
750                }
751                if provider.contains("kubernetes")
752                    || provider.contains("k8s")
753                    || key.contains("kubernetes")
754                    || key == "k8s"
755                {
756                    any_k8s = true;
757                }
758            }
759        } else if let Some(provider) = deploy.provider.as_deref() {
760            let p = provider.to_ascii_lowercase();
761            if p.contains("cloudflare") || p.contains("worker") {
762                any_cf = true;
763            }
764            if p.contains("kubernetes") || p.contains("k8s") {
765                any_k8s = true;
766            }
767        }
768    }
769
770    match (any_cf, any_k8s) {
771        (true, false) => TargetDestProfile::CloudflareOnly,
772        (false, true) => TargetDestProfile::KubernetesOnly,
773        (true, true) => TargetDestProfile::Mixed,
774        (false, false) => TargetDestProfile::Unknown,
775    }
776}
777
778fn target_has_dockerfile(config: &XbpConfig, target: &str) -> bool {
779    let names = services_for_target(config, target);
780    let services = config.services.as_deref().unwrap_or(&[]);
781    if names.is_empty() {
782        // "all" or unknown — any service with dockerfile
783        return services.iter().any(|s| {
784            s.oci
785                .as_ref()
786                .and_then(|o| o.dockerfile.as_ref())
787                .is_some_and(|d| !d.trim().is_empty())
788        });
789    }
790    names.iter().any(|name| {
791        services.iter().any(|s| {
792            s.name == *name
793                && s.oci
794                    .as_ref()
795                    .and_then(|o| o.dockerfile.as_ref())
796                    .is_some_and(|d| !d.trim().is_empty())
797        })
798    })
799}
800
801/// True when the active/requested kube context is a local cluster that shares Docker.
802pub fn looks_like_local_kube_context(explicit: Option<&str>) -> bool {
803    let ctx = explicit
804        .map(str::trim)
805        .filter(|s| !s.is_empty())
806        .map(str::to_string)
807        .or_else(current_kubectl_context_name);
808    let Some(ctx) = ctx else {
809        return false;
810    };
811    let c = ctx.to_ascii_lowercase();
812    c == "docker-desktop"
813        || c == "docker-for-desktop"
814        || c == "rancher-desktop"
815        || c == "minikube"
816        || c.starts_with("kind-")
817        || c.starts_with("k3d-")
818        || c.contains("desktop")
819}
820
821fn current_kubectl_context_name() -> Option<String> {
822    let output = std::process::Command::new("kubectl")
823        .args(["config", "current-context"])
824        .output()
825        .ok()?;
826    if !output.status.success() {
827        return None;
828    }
829    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
830    if s.is_empty() {
831        None
832    } else {
833        Some(s)
834    }
835}
836
837#[derive(Debug, Clone)]
838struct TargetChoice {
839    /// Value passed to the engine.
840    value: String,
841    /// Human label for the picker.
842    label: String,
843}
844
845fn deploy_target_choices(config: &XbpConfig) -> Vec<TargetChoice> {
846    let mut out = Vec::new();
847    let all_services = config.services.as_deref().unwrap_or(&[]);
848    // Prefer runtime deploy candidates (not pure scoped npm libraries).
849    let services: Vec<_> = all_services
850        .iter()
851        .filter(|s| crate::commands::deploy_engine::setup::service_is_deploy_candidate(s))
852        .collect();
853
854    // Column widths for aligned, scannable picker rows (plain text — fuzzy match stays clean).
855    let name_w = services
856        .iter()
857        .map(|s| s.name.chars().count())
858        .chain(
859            config
860                .deploy
861                .as_ref()
862                .map(|d| d.groups.keys().map(|k| k.chars().count()).collect::<Vec<_>>())
863                .unwrap_or_default(),
864        )
865        .max()
866        .unwrap_or(8)
867        .clamp(8, 28);
868
869    if let Some(deploy) = config.deploy.as_ref() {
870        let known: std::collections::BTreeSet<&str> =
871            all_services.iter().map(|s| s.name.as_str()).collect();
872        let mut group_names: Vec<_> = deploy.groups.keys().cloned().collect();
873        group_names.sort();
874        for name in group_names {
875            let group = &deploy.groups[&name];
876            let members_src = if group.order.is_empty() {
877                &group.services
878            } else {
879                &group.order
880            };
881            let members: Vec<&str> = members_src
882                .iter()
883                .map(String::as_str)
884                .filter(|n| known.contains(*n))
885                .collect();
886            if members.is_empty() {
887                continue;
888            }
889            let members_preview = if members.len() > 8 {
890                format!(
891                    "{}, … (+{})",
892                    members[..8].join(", "),
893                    members.len() - 8
894                )
895            } else {
896                members.join(", ")
897            };
898            let desc = group
899                .description
900                .as_deref()
901                .filter(|s| !s.is_empty())
902                .map(|d| format!("  ·  {d}"))
903                .unwrap_or_default();
904            out.push(TargetChoice {
905                value: name.clone(),
906                label: format!(
907                    "{}  {}  [{} svc]  [{}]{}",
908                    pad_label("GROUP", 7),
909                    pad_label(&name, name_w),
910                    members.len(),
911                    members_preview,
912                    desc
913                ),
914            });
915        }
916    }
917
918    for svc in &services {
919        let provider = svc
920            .deploy
921            .as_ref()
922            .and_then(|d| d.provider.as_deref())
923            .filter(|s| !s.is_empty())
924            .unwrap_or(svc.target.as_str());
925        let envs: Vec<_> = svc
926            .deploy
927            .as_ref()
928            .map(|d| {
929                let mut keys: Vec<_> = d.envs.keys().cloned().collect();
930                keys.sort();
931                keys
932            })
933            .unwrap_or_default();
934        let env_hint = if envs.is_empty() {
935            "no deploy.envs".to_string()
936        } else {
937            format!("envs {}", envs.join(", "))
938        };
939        out.push(TargetChoice {
940            value: svc.name.clone(),
941            label: format!(
942                "{}  {}  {}  ·  {}  ·  :{}",
943                pad_label("SVC", 7),
944                pad_label(&svc.name, name_w),
945                pad_label(provider, 12),
946                env_hint,
947                svc.port
948            ),
949        });
950    }
951
952    if !services.is_empty() {
953        out.push(TargetChoice {
954            value: "all".into(),
955            label: format!(
956                "{}  {}  every deployable service with deploy.envs ({})",
957                pad_label("ALL", 7),
958                pad_label("all", name_w),
959                services.len()
960            ),
961        });
962    }
963
964    out
965}
966
967fn format_target_help(choices: &[TargetChoice]) -> String {
968    choices
969        .iter()
970        .map(|c| format!("  • {}", c.value))
971        .collect::<Vec<_>>()
972        .join("\n")
973}
974
975fn prompt_target(config: &XbpConfig, choices: &[TargetChoice]) -> Result<String, String> {
976    print_picker_header(
977        "xbp deploy  ·  pick a target",
978        "GROUP / SVC / ALL  ·  type to fuzzy-filter  ·  Enter selects  ·  Esc cancels",
979    );
980
981    let default_idx = default_target_index(config, choices);
982    let labels: Vec<&str> = choices.iter().map(|c| c.label.as_str()).collect();
983    let idx = searchable_select_required("Deploy target", &labels, default_idx)?;
984    Ok(choices[idx].value.clone())
985}
986
987fn default_target_index(config: &XbpConfig, choices: &[TargetChoice]) -> usize {
988    // Prefer sole service, else first group, else first choice.
989    let services = config.services.as_deref().unwrap_or(&[]);
990    if services.len() == 1 {
991        if let Some(i) = choices.iter().position(|c| c.value == services[0].name) {
992            return i;
993        }
994    }
995    if let Some(i) = choices.iter().position(|c| c.label.starts_with("GROUP")) {
996        return i;
997    }
998    0
999}
1000
1001fn prompt_mode(current: DeployMode) -> Result<DeployMode, String> {
1002    print_picker_header(
1003        "xbp deploy  ·  mode",
1004        "How should this deploy run? (plan is safe / no mutations)",
1005    );
1006    let modes = [
1007        (DeployMode::Run, "run      ·  apply providers (k8s / worker / …)"),
1008        (DeployMode::Plan, "plan     ·  print plan only (no mutations)"),
1009        (DeployMode::Verify, "verify   ·  read-only rollout/health checks"),
1010        (DeployMode::Status, "status   ·  status snapshot"),
1011        (DeployMode::History, "history  ·  recent deploy records"),
1012        (
1013            DeployMode::Promote,
1014            "promote  ·  retag OCI images by digest (prompts for tag)",
1015        ),
1016    ];
1017    // Interactive default is `run` (apply). If the caller already set a non-plan mode
1018    // (e.g. --verify), highlight that entry instead.
1019    let default = if matches!(current, DeployMode::Plan) {
1020        0 // run is first in the list
1021    } else {
1022        modes
1023            .iter()
1024            .position(|(m, _)| std::mem::discriminant(m) == std::mem::discriminant(&current))
1025            .unwrap_or(0)
1026    };
1027    let labels: Vec<&str> = modes.iter().map(|(_, l)| *l).collect();
1028    let idx = select_one("Deploy mode", &labels, default)?
1029        .ok_or_else(|| "Selection cancelled".to_string())?;
1030    Ok(modes[idx].0)
1031}
1032
1033/// Common promotion tags + free-form input.
1034fn prompt_promote_tag(
1035    config: &XbpConfig,
1036    target: &str,
1037    env: &Option<String>,
1038) -> Result<String, String> {
1039    println!();
1040    println!(
1041        "{} retag images for `{}` by digest (no rebuild)",
1042        "promote".bright_cyan().bold(),
1043        target.bright_white()
1044    );
1045    println!(
1046        "{}",
1047        "The source digest comes from the plan/lock; the tag is the new mutable pointer (e.g. stable, production)."
1048            .bright_black()
1049    );
1050
1051    let mut suggestions: Vec<String> = vec![
1052        "stable".into(),
1053        "latest".into(),
1054        "production".into(),
1055        "staging".into(),
1056        "canary".into(),
1057    ];
1058    if let Some(e) = env.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
1059        if !suggestions.iter().any(|s| s == e) {
1060            suggestions.insert(0, e.to_string());
1061        }
1062    }
1063    if let Some(default_env) = config
1064        .deploy
1065        .as_ref()
1066        .and_then(|d| d.default_env.as_deref())
1067        .map(str::trim)
1068        .filter(|s| !s.is_empty())
1069    {
1070        if !suggestions.iter().any(|s| s == default_env) {
1071            suggestions.insert(0, default_env.to_string());
1072        }
1073    }
1074    // Dedup while preserving order
1075    let mut seen = BTreeSet::new();
1076    suggestions.retain(|s| seen.insert(s.clone()));
1077
1078    let mut labels: Vec<String> = suggestions
1079        .iter()
1080        .map(|t| format!(":{t}"))
1081        .collect();
1082    labels.push("custom tag…".into());
1083
1084    let idx = select_one("Promotion tag", &labels, 0)?
1085        .ok_or_else(|| "Selection cancelled".to_string())?;
1086
1087    let tag = if idx < suggestions.len() {
1088        suggestions[idx].clone()
1089    } else {
1090        Input::with_theme(&xbp_theme())
1091            .with_prompt("Custom promotion tag (no leading colon)")
1092            .validate_with(|input: &String| -> Result<(), &str> {
1093                let t = input.trim();
1094                if t.is_empty() {
1095                    return Err("tag must not be empty");
1096                }
1097                if t.contains(char::is_whitespace) {
1098                    return Err("tag must not contain whitespace");
1099                }
1100                if t.starts_with(':') {
1101                    return Err("omit the leading colon (enter stable, not :stable)");
1102                }
1103                if t.contains('/') || t.contains('@') {
1104                    return Err("enter a bare tag, not a full image reference");
1105                }
1106                Ok(())
1107            })
1108            .interact_text()
1109            .map_err(|e| e.to_string())?
1110            .trim()
1111            .to_string()
1112    };
1113
1114    println!(
1115        "{} will retag plan digests → `…:{}`",
1116        "promote".bright_green().bold(),
1117        tag.bright_white()
1118    );
1119    Ok(tag)
1120}
1121
1122fn prompt_env(config: &XbpConfig, target: &str) -> Result<Option<String>, String> {
1123    let mut envs: BTreeSet<String> = BTreeSet::new();
1124    if let Some(default) = config
1125        .deploy
1126        .as_ref()
1127        .and_then(|d| d.default_env.clone())
1128        .filter(|s| !s.trim().is_empty())
1129    {
1130        envs.insert(default);
1131    }
1132    envs.insert("production".into());
1133    envs.insert("staging".into());
1134    envs.insert("development".into());
1135
1136    let services = config.services.as_deref().unwrap_or(&[]);
1137    let names = services_for_target(config, target);
1138    for name in names {
1139        if let Some(svc) = services.iter().find(|s| s.name == name) {
1140            if let Some(deploy) = &svc.deploy {
1141                for env in deploy.envs.keys() {
1142                    envs.insert(env.clone());
1143                }
1144            }
1145        }
1146    }
1147
1148    let mut list: Vec<String> = envs.into_iter().collect();
1149    list.sort();
1150    // Prefer project default first in the list.
1151    if let Some(default) = config
1152        .deploy
1153        .as_ref()
1154        .and_then(|d| d.default_env.as_deref())
1155    {
1156        if let Some(i) = list.iter().position(|e| e == default) {
1157            let d = list.remove(i);
1158            list.insert(0, d);
1159        }
1160    } else if let Some(i) = list.iter().position(|e| e == "production") {
1161        let d = list.remove(i);
1162        list.insert(0, d);
1163    }
1164
1165    if list.len() == 1 {
1166        return Ok(Some(list[0].clone()));
1167    }
1168
1169    let labels: Vec<String> = list
1170        .iter()
1171        .map(|e| {
1172            if config
1173                .deploy
1174                .as_ref()
1175                .and_then(|d| d.default_env.as_deref())
1176                == Some(e.as_str())
1177            {
1178                format!("{e} (project default)")
1179            } else {
1180                e.clone()
1181            }
1182        })
1183        .collect();
1184    print_picker_header(
1185        "xbp deploy  ·  environment",
1186        "Environment keys from deploy.default_env + service deploy.envs",
1187    );
1188    let idx = select_one("Deploy environment", &labels, 0)?
1189        .ok_or_else(|| "Selection cancelled".to_string())?;
1190    Ok(Some(list[idx].clone()))
1191}
1192
1193fn services_for_target(config: &XbpConfig, target: &str) -> Vec<String> {
1194    let raw = target.trim();
1195    let services = config.services.as_deref().unwrap_or(&[]);
1196    if raw.eq_ignore_ascii_case("all") {
1197        return services.iter().map(|s| s.name.clone()).collect();
1198    }
1199    if let Some(group) = config.deploy.as_ref().and_then(|d| d.groups.get(raw)) {
1200        return if group.order.is_empty() {
1201            group.services.clone()
1202        } else {
1203            group.order.clone()
1204        };
1205    }
1206    if services.iter().any(|s| s.name == raw) {
1207        return vec![raw.to_string()];
1208    }
1209    Vec::new()
1210}
1211
1212fn is_interactive() -> bool {
1213    std::io::stdin().is_terminal()
1214        && std::io::stdout().is_terminal()
1215        && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
1216}
1217
1218/// After a multi-service plan is built, optionally let the operator deselect services.
1219///
1220/// Skipped when: non-interactive, `--yes`, single service, or plan/verify/run with
1221/// no TTY. History/status never call this.
1222pub fn maybe_edit_plan_services(
1223    plan: DeployPlan,
1224    yes: bool,
1225    force_non_interactive: bool,
1226) -> Result<DeployPlan, String> {
1227    let names = unique_service_names(&plan);
1228    if names.len() <= 1 {
1229        return Ok(plan);
1230    }
1231    let interactive = is_interactive() && !force_non_interactive && !yes;
1232    if !interactive {
1233        return Ok(plan);
1234    }
1235
1236    print_picker_header(
1237        "xbp deploy  ·  plan services",
1238        &format!(
1239            "{} services from target `{}`  ·  adjust before apply",
1240            names.len(),
1241            plan.target.label()
1242        ),
1243    );
1244
1245    let actions = [
1246        format!("Continue with all {} services", names.len()),
1247        "Edit services…".to_string(),
1248        "Cancel deploy".to_string(),
1249    ];
1250    // Prefer Edit when many services (accidental `all` is common).
1251    let default = if names.len() > 3 { 1 } else { 0 };
1252    let idx = select_one("Plan services", &actions, default)?
1253        .ok_or_else(|| "Selection cancelled".to_string())?;
1254
1255    match idx {
1256        0 => Ok(plan),
1257        2 => Err("deploy cancelled".into()),
1258        _ => {
1259            let labeled = service_plan_labels(&plan);
1260            let labels: Vec<&str> = labeled.iter().map(|(_, l)| l.as_str()).collect();
1261            let defaults = vec![true; labeled.len()];
1262            let selected = searchable_multi_select(
1263                "Services to include in this deploy",
1264                &labels,
1265                &defaults,
1266            )?;
1267            if selected.is_empty() {
1268                return Err("deploy cancelled: no services selected".into());
1269            }
1270            let keep: HashSet<String> = selected
1271                .into_iter()
1272                .filter_map(|i| labeled.get(i).map(|(n, _)| n.clone()))
1273                .collect();
1274            let filtered = retain_services_in_plan(plan, &keep).map_err(|e| e.to_string())?;
1275            println!(
1276                "{} plan now has {} service(s): {}",
1277                "deploy".bright_cyan().bold(),
1278                unique_service_names(&filtered).len(),
1279                unique_service_names(&filtered).join(", ")
1280            );
1281            Ok(filtered)
1282        }
1283    }
1284}
1285
1286/// Apply CLI `--only` / `--exclude` filters.
1287pub fn apply_cli_service_filters(
1288    plan: DeployPlan,
1289    only: &[String],
1290    exclude: &[String],
1291) -> Result<DeployPlan, String> {
1292    filter_deploy_plan(plan, only, exclude).map_err(|e| e.to_string())
1293}
1294
1295/// Confirm apply for multi-service run when interactive.
1296pub fn maybe_confirm_multi_service_apply(
1297    plan: &DeployPlan,
1298    mode: DeployMode,
1299    yes: bool,
1300    force_non_interactive: bool,
1301) -> Result<(), String> {
1302    if !matches!(mode, DeployMode::Run | DeployMode::Promote) {
1303        return Ok(());
1304    }
1305    if yes || force_non_interactive || !is_interactive() {
1306        return Ok(());
1307    }
1308    let n = unique_service_names(plan).len();
1309    if n <= 1 {
1310        return Ok(());
1311    }
1312    let ok = confirm(
1313        &format!("Apply deploy for {} service(s)?", n),
1314        false,
1315    )?;
1316    if !ok {
1317        return Err("deploy cancelled".into());
1318    }
1319    Ok(())
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324    use super::*;
1325
1326    fn sample_config() -> XbpConfig {
1327        serde_json::from_value(serde_json::json!({
1328            "project_name": "demo",
1329            "version": "1.0.0",
1330            "port": 8080,
1331            "build_dir": ".",
1332            "services": [
1333                {"name": "api", "target": "rust", "branch": "main", "port": 8080},
1334                {"name": "web", "target": "nextjs", "branch": "main", "port": 3000}
1335            ],
1336            "deploy": {
1337                "default_env": "production",
1338                "groups": {
1339                    "core": {
1340                        "services": ["api", "web"],
1341                        "description": "core stack"
1342                    }
1343                }
1344            }
1345        }))
1346        .expect("sample config")
1347    }
1348
1349    #[test]
1350    fn target_choices_include_group_service_and_all() {
1351        let cfg = sample_config();
1352        let choices = deploy_target_choices(&cfg);
1353        let values: Vec<_> = choices.iter().map(|c| c.value.as_str()).collect();
1354        assert!(values.contains(&"core"));
1355        assert!(values.contains(&"api"));
1356        assert!(values.contains(&"web"));
1357        assert!(values.contains(&"all"));
1358    }
1359
1360    #[test]
1361    fn default_target_prefers_group_when_multiple_services() {
1362        let cfg = sample_config();
1363        let choices = deploy_target_choices(&cfg);
1364        let idx = default_target_index(&cfg, &choices);
1365        assert_eq!(choices[idx].value, "core");
1366    }
1367
1368    #[test]
1369    fn sole_service_is_default_when_no_group() {
1370        let cfg: XbpConfig = serde_json::from_value(serde_json::json!({
1371            "project_name": "solo",
1372            "version": "0.1.0",
1373            "port": 1,
1374            "build_dir": ".",
1375            "services": [
1376                {"name": "xbp-github-runner", "target": "rust", "branch": "main", "port": 3599}
1377            ]
1378        }))
1379        .expect("solo config");
1380        let choices = deploy_target_choices(&cfg);
1381        let idx = default_target_index(&cfg, &choices);
1382        assert_eq!(choices[idx].value, "xbp-github-runner");
1383    }
1384
1385    #[test]
1386    fn peel_plan_positional_sets_plan_mode() {
1387        let peeled = peel_deploy_mode_positional(
1388            Some("plan".into()),
1389            DeployMode::Plan,
1390            false,
1391        )
1392        .expect("peel");
1393        assert_eq!(peeled.target, None);
1394        assert!(matches!(peeled.mode, DeployMode::Plan));
1395        assert!(peeled.mode_explicit);
1396    }
1397
1398    #[test]
1399    fn peel_run_positional_sets_run_mode() {
1400        let peeled = peel_deploy_mode_positional(Some("run".into()), DeployMode::Plan, false)
1401            .expect("peel");
1402        assert_eq!(peeled.target, None);
1403        assert!(matches!(peeled.mode, DeployMode::Run));
1404    }
1405
1406    #[test]
1407    fn peel_plan_with_run_flag_errors() {
1408        let err = peel_deploy_mode_positional(Some("plan".into()), DeployMode::Run, true)
1409            .expect_err("conflict");
1410        assert!(err.contains("mode"), "{err}");
1411        assert!(err.contains("--run") || err.contains("--plan"), "{err}");
1412    }
1413
1414    #[test]
1415    fn peel_service_name_unchanged() {
1416        let peeled = peel_deploy_mode_positional(
1417            Some("athena".into()),
1418            DeployMode::Plan,
1419            false,
1420        )
1421        .expect("peel");
1422        assert_eq!(peeled.target.as_deref(), Some("athena"));
1423        assert!(!peeled.mode_explicit);
1424    }
1425}