Skip to main content

xbp_cli/commands/
init_wizard.rs

1//! Comprehensive interactive post-init setup wizard.
2//!
3//! After the base project config is written (`.xbp/xbp.toml` by default),
4//! `xbp init` walks through versioning, cargo-dist, publish targets,
5//! Cloudflare Workers, OCI, Kubernetes, Discord webhooks, monitoring,
6//! and related project knobs.
7
8use crate::cli::interactive::{print_picker_header, xbp_theme};
9use crate::config::{
10    mask_registry_token, resolve_crates_token_from, resolve_linear_api_key, resolve_npm_token_from,
11    DiscordConfig, DiscordEventsConfig, LinearConfig, LinearReleaseConfig,
12};
13use crate::strategies::deployment_config::{
14    validate_github_release_branch_naming_template, DeployProjectConfig, GitHubProjectConfig,
15    GitHubReleaseBranchConfig, KubernetesProjectConfig, OciProjectConfig, OciRegistryConfig,
16    PublishDistConfig, PublishPackageEntry, PublishTargetConfig, WorkerConfig, WorkerDeployConfig,
17    XbpConfig, DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
18    GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS,
19};
20use crate::utils::{
21    collapse_project_path, first_dotenv_value_upwards, preferred_local_secret_env_path,
22    project_config_display_path, upsert_env_key, DEFAULT_DISCOVERY_SKIP_DIRS,
23};
24use colored::Colorize;
25use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, MultiSelect, Select};
26use std::collections::{BTreeSet, HashMap};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::process::Stdio;
30
31/// Setup modules offered by the interactive init wizard.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum InitSetupModule {
34    VersionTargets,
35    GitHubRelease,
36    LinearRelease,
37    CargoDist,
38    PublishCrates,
39    PublishNpm,
40    CloudflareWorkers,
41    OciRegistry,
42    Kubernetes,
43    DeployDefaults,
44    Discord,
45    Monitoring,
46}
47
48impl InitSetupModule {
49    pub fn all() -> &'static [InitSetupModule] {
50        &[
51            Self::VersionTargets,
52            Self::GitHubRelease,
53            Self::LinearRelease,
54            Self::CargoDist,
55            Self::PublishCrates,
56            Self::PublishNpm,
57            Self::CloudflareWorkers,
58            Self::OciRegistry,
59            Self::Kubernetes,
60            Self::DeployDefaults,
61            Self::Discord,
62            Self::Monitoring,
63        ]
64    }
65
66    pub fn label(self) -> &'static str {
67        match self {
68            Self::VersionTargets => "Version targets & release scope",
69            Self::GitHubRelease => "GitHub release branch + auto-push",
70            Self::LinearRelease => "Linear release posts (credentials + initiative)",
71            Self::CargoDist => "cargo-dist / dist packaging for GitHub releases",
72            Self::PublishCrates => "crates.io publish target",
73            Self::PublishNpm => "npm publish target",
74            Self::CloudflareWorkers => "Cloudflare Workers apps",
75            Self::OciRegistry => "OCI registry credentials (GHCR / etc.)",
76            Self::Kubernetes => "Kubernetes defaults (context / namespace)",
77            Self::DeployDefaults => "Deploy groups & default environment",
78            Self::Discord => "Discord webhook notifications",
79            Self::Monitoring => "Synthetic monitor URL",
80        }
81    }
82
83    pub fn default_selected(self) -> bool {
84        matches!(
85            self,
86            Self::VersionTargets
87                | Self::GitHubRelease
88                | Self::LinearRelease
89                | Self::CargoDist
90                | Self::PublishCrates
91                | Self::Discord
92                | Self::DeployDefaults
93        )
94    }
95}
96
97/// Pure planner used by tests: which modules should default on for a tree shape.
98pub fn default_modules_for_project(
99    has_cargo_toml: bool,
100    has_package_json: bool,
101    has_wrangler: bool,
102    has_dockerfile: bool,
103    has_k8s_manifests: bool,
104) -> Vec<InitSetupModule> {
105    let mut selected = vec![
106        InitSetupModule::VersionTargets,
107        InitSetupModule::GitHubRelease,
108        InitSetupModule::LinearRelease,
109        InitSetupModule::Discord,
110    ];
111    if has_cargo_toml {
112        selected.push(InitSetupModule::CargoDist);
113        selected.push(InitSetupModule::PublishCrates);
114    }
115    if has_package_json {
116        selected.push(InitSetupModule::PublishNpm);
117    }
118    if has_wrangler {
119        selected.push(InitSetupModule::CloudflareWorkers);
120    }
121    if has_dockerfile {
122        selected.push(InitSetupModule::OciRegistry);
123    }
124    if has_k8s_manifests {
125        selected.push(InitSetupModule::Kubernetes);
126    }
127    selected.push(InitSetupModule::DeployDefaults);
128    selected
129}
130
131/// Build a `github.release_branch` config from wizard answers (pure; unit-tested).
132pub fn build_github_release_config(
133    repository: Option<String>,
134    auto_push_on_commit: bool,
135    release_branch_enabled: bool,
136    naming_template: Option<String>,
137) -> GitHubProjectConfig {
138    let template = naming_template
139        .map(|t| t.trim().to_string())
140        .filter(|t| !t.is_empty())
141        .and_then(|t| {
142            // Drop invalid custom templates so the default is written instead.
143            if validate_github_release_branch_naming_template(&t).is_ok() {
144                Some(t)
145            } else {
146                None
147            }
148        });
149    GitHubProjectConfig {
150        repository: repository
151            .map(|r| r.trim().to_string())
152            .filter(|r| !r.is_empty()),
153        auto_push_on_commit,
154        release_branch: if release_branch_enabled {
155            Some(GitHubReleaseBranchConfig {
156                enabled: true,
157                naming_template: template.or_else(|| {
158                    Some(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE.to_string())
159                }),
160            })
161        } else {
162            None
163        },
164        runners: None,
165    }
166}
167
168/// Build `linear.release` config from wizard answers (pure; unit-tested).
169pub fn build_linear_release_config(
170    enabled: bool,
171    initiative_ids: Vec<String>,
172    organization_name: Option<String>,
173) -> LinearConfig {
174    let org = organization_name
175        .map(|s| s.trim().to_string())
176        .filter(|s| !s.is_empty());
177    LinearConfig {
178        release: Some(LinearReleaseConfig {
179            enabled: Some(enabled),
180            initiative_ids: if initiative_ids.is_empty() {
181                None
182            } else {
183                Some(initiative_ids)
184            },
185            organization_name: org.clone(),
186            health: None,
187        }),
188        default_team_id: None,
189        default_team_key: None,
190        organization_name: org,
191    }
192}
193
194/// Detect common project markers used to pre-select wizard modules.
195pub fn detect_project_markers(project_root: &Path) -> ProjectMarkers {
196    ProjectMarkers {
197        has_cargo_toml: project_root.join("Cargo.toml").exists()
198            || project_root.join("crates").exists(),
199        has_package_json: project_root.join("package.json").exists()
200            || project_root.join("apps").exists()
201            || project_root.join("packages").exists(),
202        has_wrangler: find_wrangler_config(project_root).is_some(),
203        has_dockerfile: project_root.join("Dockerfile").exists()
204            || project_root.join("docker-compose.yml").exists()
205            || project_root.join("compose.yml").exists(),
206        has_k8s_manifests: project_root.join("k8s").exists()
207            || project_root.join("deploy/k8s").exists()
208            || project_root.join("charts").exists(),
209        has_dist_workspace: project_root.join("dist-workspace.toml").exists(),
210    }
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct ProjectMarkers {
215    pub has_cargo_toml: bool,
216    pub has_package_json: bool,
217    pub has_wrangler: bool,
218    pub has_dockerfile: bool,
219    pub has_k8s_manifests: bool,
220    pub has_dist_workspace: bool,
221}
222
223fn find_wrangler_config(project_root: &Path) -> Option<PathBuf> {
224    let candidates = [
225        project_root.join("wrangler.toml"),
226        project_root.join("wrangler.jsonc"),
227        project_root.join("wrangler.json"),
228        project_root.join("apps/web/wrangler.jsonc"),
229        project_root.join("apps/web/wrangler.toml"),
230        project_root.join("apps/docs/wrangler.jsonc"),
231        project_root.join("apps/api/wrangler.jsonc"),
232    ];
233    candidates.into_iter().find(|p| p.exists())
234}
235
236/// Run the interactive full-setup wizard and persist updates to project config.
237///
238/// Always enters the module picker — `xbp init` is the wizard entrypoint.
239pub async fn run_init_setup_wizard(
240    project_root: &Path,
241    config: &mut XbpConfig,
242) -> Result<Vec<String>, String> {
243    let theme = xbp_theme();
244    print_picker_header(
245        "xbp init  ·  full setup",
246        "Walk through release, publish, Workers, OCI/K8s, Discord, and more. Skip any step.",
247    );
248
249    let markers = detect_project_markers(project_root);
250    let defaults = default_modules_for_project(
251        markers.has_cargo_toml || markers.has_dist_workspace,
252        markers.has_package_json,
253        markers.has_wrangler,
254        markers.has_dockerfile,
255        markers.has_k8s_manifests,
256    );
257    let modules = InitSetupModule::all();
258    let default_flags: Vec<bool> = modules
259        .iter()
260        .map(|m| defaults.contains(m) || m.default_selected())
261        .collect();
262
263    let labels: Vec<&str> = modules.iter().map(|m| m.label()).collect();
264    let chosen = MultiSelect::with_theme(&theme)
265        .with_prompt("Select setup modules (space to toggle, enter to confirm)")
266        .items(&labels)
267        .defaults(&default_flags)
268        .interact()
269        .map_err(|e| e.to_string())?;
270
271    let selected: Vec<InitSetupModule> = chosen.into_iter().map(|i| modules[i]).collect();
272    if selected.is_empty() {
273        println!("{}", "No modules selected.".yellow());
274        return Ok(Vec::new());
275    }
276
277    let mut notes = Vec::new();
278    for module in selected {
279        println!();
280        println!(
281            "{} {}",
282            "▸".bright_cyan().bold(),
283            module.label().bright_white().bold()
284        );
285        match module {
286            InitSetupModule::VersionTargets => {
287                setup_version_targets(project_root, config, &theme, &mut notes)?;
288            }
289            InitSetupModule::GitHubRelease => {
290                setup_github_release(project_root, config, &theme, &mut notes).await?;
291            }
292            InitSetupModule::LinearRelease => {
293                setup_linear_release(config, &theme, &mut notes).await?;
294            }
295            InitSetupModule::CargoDist => {
296                setup_cargo_dist(config, project_root, &markers, &theme, &mut notes)?;
297            }
298            InitSetupModule::PublishCrates => {
299                setup_publish_target(config, "crates", project_root, &theme, &mut notes).await?;
300            }
301            InitSetupModule::PublishNpm => {
302                setup_publish_target(config, "npm", project_root, &theme, &mut notes).await?;
303            }
304            InitSetupModule::CloudflareWorkers => {
305                setup_cloudflare_workers(project_root, config, &theme, &mut notes)?;
306            }
307            InitSetupModule::OciRegistry => {
308                setup_oci(project_root, config, &theme, &mut notes)?;
309            }
310            InitSetupModule::Kubernetes => {
311                setup_kubernetes(config, &theme, &mut notes)?;
312            }
313            InitSetupModule::DeployDefaults => {
314                setup_deploy_defaults(config, &theme, &mut notes)?;
315            }
316            InitSetupModule::Discord => {
317                setup_discord(project_root, config, &theme, &mut notes)?;
318            }
319            InitSetupModule::Monitoring => {
320                setup_monitoring(config, &theme, &mut notes)?;
321            }
322        }
323    }
324
325    // Apply build/start/oci/deploy lifecycle defaults to every service after modules ran.
326    crate::commands::project_services::apply_service_defaults_after_init(config, project_root);
327    notes.push("services[] lifecycle defaults applied (commands, oci, deploy envs)".into());
328
329    let config_path = crate::utils::resolve_project_config_write_path(
330        project_root,
331        crate::utils::find_xbp_config_upwards(project_root).as_ref(),
332    );
333    crate::utils::write_xbp_project_config_at_path(&config_path, config)
334        .map_err(|e| format!("Write {}: {e}", config_path.display()))?;
335    notes.push(format!("Updated {}", config_path.display()));
336
337    println!();
338    println!(
339        "{} {}",
340        "Done.".green().bold(),
341        "Project setup modules applied.".bright_white()
342    );
343    println!("{}", "Next steps:".bright_cyan().bold());
344    println!("  {}  validate host readiness", "xbp diag --full".dimmed());
345    println!("  {}  bump & release", "xbp version release".dimmed());
346    println!("  {}  plan deploys", "xbp deploy all --plan".dimmed());
347    println!(
348        "  {}  Discord test",
349        "xbp config discord test".dimmed()
350    );
351    if config.workers.as_ref().map(|w| !w.is_empty()).unwrap_or(false) {
352        println!(
353            "  {}  Workers",
354            "xbp workers list / xbp workers deploy".dimmed()
355        );
356    }
357
358    Ok(vec![config_path.display().to_string()])
359}
360
361fn setup_version_targets(
362    project_root: &Path,
363    config: &mut XbpConfig,
364    theme: &ColorfulTheme,
365    notes: &mut Vec<String>,
366) -> Result<(), String> {
367    let discovered = crate::commands::project_services::discover_service_version_targets_for_project(
368        project_root,
369        project_root,
370        &config.ignore_paths,
371    );
372    if !discovered.is_empty() {
373        println!(
374            "  Discovered {} version target(s):",
375            discovered.len()
376        );
377        for t in discovered.iter().take(12) {
378            println!("    • {}", t.dimmed());
379        }
380        if discovered.len() > 12 {
381            println!("    … and {} more", discovered.len() - 12);
382        }
383    }
384
385    let use_discovered = if discovered.is_empty() {
386        false
387    } else {
388        Confirm::with_theme(theme)
389            .with_prompt("Use discovered version targets?")
390            .default(true)
391            .interact()
392            .map_err(|e| e.to_string())?
393    };
394
395    if use_discovered {
396        for t in discovered {
397            if !config.version_targets.iter().any(|e| e == &t) {
398                config.version_targets.push(t);
399            }
400        }
401        config.version_targets.sort();
402        notes.push(format!(
403            "version_targets ← {} path(s)",
404            config.version_targets.len()
405        ));
406    } else {
407        let extra: String = Input::with_theme(theme)
408            .with_prompt("Extra version targets (comma-separated paths, empty to skip)")
409            .allow_empty(true)
410            .interact_text()
411            .map_err(|e| e.to_string())?;
412        for part in extra.split(',') {
413            let t = part.trim();
414            if !t.is_empty() && !config.version_targets.iter().any(|e| e == t) {
415                config.version_targets.push(t.to_string());
416            }
417        }
418    }
419
420    let enable_release = Confirm::with_theme(theme)
421        .with_prompt("Enable repository-wide releases (`xbp version release`)?")
422        .default(true)
423        .interact()
424        .map_err(|e| e.to_string())?;
425    if !enable_release {
426        if let Some(name) = config.services.as_ref().and_then(|s| s.first()).map(|s| s.name.clone()) {
427            if !config.release_disabled.iter().any(|x| x == &name) {
428                config.release_disabled.push(name);
429            }
430        }
431        notes.push("release disabled for default service (adjust release_disabled as needed)".into());
432    } else {
433        notes.push("releases enabled — use `xbp version release`".into());
434    }
435    Ok(())
436}
437
438fn setup_cargo_dist(
439    config: &mut XbpConfig,
440    project_root: &Path,
441    markers: &ProjectMarkers,
442    theme: &ColorfulTheme,
443    notes: &mut Vec<String>,
444) -> Result<(), String> {
445    let enable = Confirm::with_theme(theme)
446        .with_prompt(format!(
447            "Enable cargo-dist packaging on release?{}",
448            if markers.has_dist_workspace {
449                " (dist-workspace.toml detected)"
450            } else {
451                ""
452            }
453        ))
454        .default(markers.has_dist_workspace || markers.has_cargo_toml)
455        .interact()
456        .map_err(|e| e.to_string())?;
457    if !enable {
458        notes.push("cargo-dist left disabled".into());
459        return Ok(());
460    }
461
462    let installer_options = [
463        ("shell", "Unix shell installer"),
464        ("msi", "Windows MSI"),
465        ("powershell", "Windows PowerShell installer"),
466        ("npm", "npm global installer package"),
467        ("homebrew", "Homebrew formula"),
468    ];
469    let labels: Vec<String> = installer_options
470        .iter()
471        .map(|(id, desc)| format!("{id}  —  {desc}"))
472        .collect();
473    let defaults: Vec<bool> = installer_options
474        .iter()
475        .map(|(id, _)| *id == "shell" || *id == "msi")
476        .collect();
477    print_picker_header(
478        "cargo-dist  ·  installers",
479        "Space toggles  ·  Enter confirms  ·  multi-select",
480    );
481    let chosen = MultiSelect::with_theme(theme)
482        .with_prompt("Installers to enable")
483        .items(&labels)
484        .defaults(&defaults)
485        .interact()
486        .map_err(|e| e.to_string())?;
487    let installers: Vec<String> = if chosen.is_empty() {
488        vec!["shell".into(), "msi".into()]
489    } else {
490        chosen
491            .into_iter()
492            .map(|i| installer_options[i].0.to_string())
493            .collect()
494    };
495
496    // Prefer crate package names already chosen for publish, else discover.
497    let mut packages = config
498        .publish
499        .as_ref()
500        .and_then(|p| p.crates.as_ref())
501        .map(|c| {
502            c.package_entries()
503                .into_iter()
504                .filter_map(|e| e.package_name)
505                .collect::<Vec<_>>()
506        })
507        .unwrap_or_default();
508    if packages.is_empty() {
509        packages = discover_publish_packages(project_root, "crates")
510            .into_iter()
511            .map(|p| p.package_name)
512            .collect();
513    }
514    if packages.len() > 1 {
515        let defaults: Vec<bool> = packages.iter().map(|_| true).collect();
516        print_picker_header(
517            "cargo-dist  ·  packages",
518            "Which crates should cargo-dist package on release?",
519        );
520        let chosen = MultiSelect::with_theme(theme)
521            .with_prompt("cargo-dist packages")
522            .items(&packages)
523            .defaults(&defaults)
524            .interact()
525            .map_err(|e| e.to_string())?;
526        if !chosen.is_empty() {
527            packages = chosen.into_iter().map(|i| packages[i].clone()).collect();
528        }
529    }
530
531    let generate_ci = Confirm::with_theme(theme)
532        .with_prompt("Generate/update cargo-dist GitHub Actions CI?")
533        .default(true)
534        .interact()
535        .map_err(|e| e.to_string())?;
536
537    let mut publish = config.publish.clone().unwrap_or_default();
538    publish.dist = Some(PublishDistConfig {
539        enabled: Some(true),
540        packages,
541        installers: installers.clone(),
542        targets: Vec::new(),
543        artifacts_modes: vec!["host".into(), "global".into()],
544        allow_dirty: Some(true),
545        generate_ci: Some(generate_ci),
546        tool: None,
547    });
548    config.publish = Some(publish);
549    notes.push(format!(
550        "publish.dist enabled (installers: {})",
551        installers.join(", ")
552    ));
553    Ok(())
554}
555
556async fn setup_publish_target(
557    config: &mut XbpConfig,
558    provider: &str,
559    project_root: &Path,
560    theme: &ColorfulTheme,
561    notes: &mut Vec<String>,
562) -> Result<(), String> {
563    let enable = Confirm::with_theme(theme)
564        .with_prompt(format!("Configure {provider} publish target(s)?"))
565        .default(true)
566        .interact()
567        .map_err(|e| e.to_string())?;
568    if !enable {
569        return Ok(());
570    }
571
572    let discovered = discover_publish_packages(project_root, provider);
573    let selected_entries = if discovered.is_empty() {
574        println!(
575            "  {}",
576            format!("No {provider} packages auto-detected — enter manually.")
577                .yellow()
578        );
579        let default_pkg = config.project_name.clone();
580        let default_manifest = if provider == "crates" {
581            if project_root.join("Cargo.toml").exists() {
582                "Cargo.toml"
583            } else {
584                "crates/cli/Cargo.toml"
585            }
586        } else if project_root.join("package.json").exists() {
587            "package.json"
588        } else {
589            "packages/web/package.json"
590        };
591        let package_name: String = Input::with_theme(theme)
592            .with_prompt(format!("{provider} package name"))
593            .with_initial_text(default_pkg)
594            .interact_text()
595            .map_err(|e| e.to_string())?;
596        let manifest_path: String = Input::with_theme(theme)
597            .with_prompt("Manifest path (repo-relative)")
598            .with_initial_text(default_manifest)
599            .interact_text()
600            .map_err(|e| e.to_string())?;
601        let working_directory = Path::new(manifest_path.trim())
602            .parent()
603            .map(|p| p.to_string_lossy().replace('\\', "/"))
604            .filter(|p| !p.is_empty())
605            .unwrap_or_else(|| ".".to_string());
606        vec![PublishPackageEntry {
607            package_name: Some(package_name.trim().to_string()),
608            working_directory: Some(working_directory),
609            manifest_path: Some(manifest_path.trim().to_string()),
610        }]
611    } else {
612        print_picker_header(
613            &format!("xbp init  ·  {provider} packages"),
614            "Discovered via project detector (Cargo.toml / package.json). Space toggles · multi-select monorepo packages.",
615        );
616        let labels: Vec<String> = discovered
617            .iter()
618            .map(|p| {
619                format!(
620                    "{}  ·  {}",
621                    pad_col(&p.package_name, 32),
622                    p.manifest_path
623                )
624            })
625            .collect();
626        let defaults: Vec<bool> = discovered.iter().map(|_| true).collect();
627        let chosen = MultiSelect::with_theme(theme)
628            .with_prompt(format!("Select {provider} package(s) to publish"))
629            .items(&labels)
630            .defaults(&defaults)
631            .interact()
632            .map_err(|e| e.to_string())?;
633        let indices: Vec<usize> = if chosen.is_empty() {
634            vec![0]
635        } else {
636            chosen
637        };
638        indices
639            .into_iter()
640            .filter_map(|i| discovered.get(i).cloned())
641            .map(|p| PublishPackageEntry {
642                package_name: Some(p.package_name),
643                working_directory: Some(p.working_directory),
644                manifest_path: Some(p.manifest_path),
645            })
646            .collect()
647    };
648
649    if selected_entries.is_empty() {
650        notes.push(format!("publish.{provider} skipped (no packages selected)"));
651        return Ok(());
652    }
653
654    // Ensure version_targets include every selected manifest.
655    for entry in &selected_entries {
656        if let Some(manifest) = entry.manifest_path.as_deref() {
657            let rel = collapse_project_path(project_root, manifest);
658            if !config.version_targets.iter().any(|t| t == &rel || t == manifest) {
659                config.version_targets.push(rel);
660            }
661        }
662    }
663
664    let token_placeholder = if provider == "crates" {
665        "${CARGO_REGISTRY_TOKEN}"
666    } else {
667        "${NPM_TOKEN}"
668    };
669    let token_label = if provider == "crates" {
670        "CARGO_REGISTRY_TOKEN"
671    } else {
672        "NPM_TOKEN"
673    };
674
675    let resolved = if provider == "crates" {
676        resolve_crates_token_from(Some(project_root))
677    } else {
678        resolve_npm_token_from(Some(project_root))
679    };
680
681    let mut token_ok = false;
682    match &resolved {
683        Some(token) => {
684            println!(
685                "  {} {} detected ({})",
686                "✓".bright_green().bold(),
687                token_label.bright_white(),
688                mask_registry_token(token).dimmed()
689            );
690            match verify_registry_token(provider, project_root, token).await {
691                Ok(detail) => {
692                    println!(
693                        "  {} {} token valid{}",
694                        "✓".bright_green().bold(),
695                        token_label,
696                        detail
697                            .as_deref()
698                            .map(|d| format!(" ({d})"))
699                            .unwrap_or_default()
700                            .bright_black()
701                    );
702                    notes.push(format!("{token_label} auto-detected and verified"));
703                    token_ok = true;
704                }
705                Err(err) => {
706                    println!(
707                        "  {} {} present but verification failed: {}",
708                        "!".bright_yellow().bold(),
709                        token_label,
710                        err.dimmed()
711                    );
712                    notes.push(format!(
713                        "{token_label} found but verification failed — fix before publish"
714                    ));
715                }
716            }
717        }
718        None => {
719            println!(
720                "  {} {} not found in process env, project .env/.env.local, or global config",
721                "·".bright_black(),
722                token_label.yellow()
723            );
724            notes.push(format!(
725                "{token_label} not detected — publish will need it at release time"
726            ));
727        }
728    }
729
730    // Prefer writing the secret into project .env (gitignored) rather than only a
731    // config placeholder — verification failures and missing tokens are the common case.
732    if !token_ok {
733        let env_path = preferred_local_secret_env_path(project_root);
734        let env_display = collapse_project_path(project_root, &env_path.to_string_lossy());
735        let prompt_secret = Confirm::with_theme(theme)
736            .with_prompt(format!(
737                "Paste a valid {token_label} now and store it in `{env_display}`?"
738            ))
739            .default(true)
740            .interact()
741            .map_err(|e| e.to_string())?;
742        if prompt_secret {
743            let secret: String = Input::with_theme(theme)
744                .with_prompt(format!("{token_label} secret (written to {env_display} only)"))
745                .allow_empty(false)
746                .interact_text()
747                .map_err(|e| e.to_string())?;
748            let secret = secret.trim().to_string();
749            if !secret.is_empty() {
750                ensure_dotenv_gitignored(project_root)?;
751                upsert_env_key(&env_path, token_label, &secret)?;
752                println!(
753                    "  {} wrote {} to {} (gitignored)",
754                    "✓".bright_green().bold(),
755                    token_label.bright_white(),
756                    env_display.bright_white()
757                );
758                match verify_registry_token(provider, project_root, &secret).await {
759                    Ok(detail) => {
760                        println!(
761                            "  {} {} token valid{}",
762                            "✓".bright_green().bold(),
763                            token_label,
764                            detail
765                                .as_deref()
766                                .map(|d| format!(" ({d})"))
767                                .unwrap_or_default()
768                                .bright_black()
769                        );
770                        notes.push(format!("{token_label} saved to {env_display} and verified"));
771                        token_ok = true;
772                    }
773                    Err(err) => {
774                        println!(
775                            "  {} saved to {} but verification still failed: {}",
776                            "!".bright_yellow().bold(),
777                            env_display,
778                            err.dimmed()
779                        );
780                        notes.push(format!(
781                            "{token_label} saved to {env_display} but verification failed — check token scope"
782                        ));
783                    }
784                }
785            }
786        } else {
787            println!(
788                "  {}",
789                format!(
790                    "Add {token_label} to `{env_display}` later, or run `xbp config {provider} set-key`."
791                )
792                .bright_black()
793            );
794        }
795        let _ = token_ok;
796    }
797
798    // Always keep secret files out of git when publish is configured.
799    ensure_dotenv_gitignored(project_root)?;
800
801    let token: String = Input::with_theme(theme)
802        .with_prompt("Token env placeholder (stored in config, not the secret)")
803        .with_initial_text(token_placeholder)
804        .interact_text()
805        .map_err(|e| e.to_string())?;
806
807    let publish_command = if provider == "crates" {
808        "cargo publish --locked"
809    } else {
810        "npm publish --access public"
811    };
812
813    let primary = &selected_entries[0];
814    let packages = if selected_entries.len() > 1 {
815        selected_entries.clone()
816    } else {
817        Vec::new()
818    };
819
820    let target = PublishTargetConfig {
821        enabled: Some(true),
822        package_name: primary.package_name.clone(),
823        working_directory: primary.working_directory.clone(),
824        manifest_path: primary.manifest_path.clone(),
825        packages,
826        token: Some(token.trim().to_string()),
827        preflight_commands: Vec::new(),
828        publish_command: Some(publish_command.into()),
829        use_wsl: Some(false),
830        wsl_distribution: None,
831        generate_npmrc: if provider == "npm" { Some(true) } else { None },
832        access: if provider == "npm" {
833            Some("public".into())
834        } else {
835            None
836        },
837        ..Default::default()
838    };
839
840    let names: Vec<String> = selected_entries
841        .iter()
842        .filter_map(|e| e.package_name.clone())
843        .collect();
844
845    let mut publish = config.publish.clone().unwrap_or_default();
846    if provider == "crates" {
847        publish.crates = Some(target);
848        // Keep cargo-dist package list in sync when already configured.
849        if let Some(dist) = publish.dist.as_mut() {
850            if dist.packages.is_empty() {
851                dist.packages = names.clone();
852            }
853        }
854    } else {
855        publish.npm = Some(target);
856    }
857    config.publish = Some(publish);
858    notes.push(format!(
859        "publish.{provider} → {} package(s): {}",
860        names.len(),
861        names.join(", ")
862    ));
863    Ok(())
864}
865
866/// Discovered publishable package (npm or crates) under a monorepo.
867#[derive(Debug, Clone, PartialEq, Eq)]
868pub struct DiscoveredPublishPackage {
869    pub package_name: String,
870    pub manifest_path: String,
871    pub working_directory: String,
872}
873
874/// Walk the tree for `Cargo.toml` / `package.json` packages (project-detector style).
875pub fn discover_publish_packages(project_root: &Path, provider: &str) -> Vec<DiscoveredPublishPackage> {
876    let manifest_name = match provider {
877        "npm" => "package.json",
878        "crates" => "Cargo.toml",
879        _ => return Vec::new(),
880    };
881
882    let mut out = Vec::new();
883    let mut seen_manifests = BTreeSet::new();
884    let mut stack = vec![project_root.to_path_buf()];
885
886    while let Some(dir) = stack.pop() {
887        let Ok(rd) = fs::read_dir(&dir) else {
888            continue;
889        };
890        for entry in rd.flatten() {
891            let path = entry.path();
892            let name = entry.file_name().to_string_lossy().to_string();
893            if path.is_dir() {
894                if DEFAULT_DISCOVERY_SKIP_DIRS
895                    .iter()
896                    .any(|skip| name.eq_ignore_ascii_case(skip))
897                    || name.starts_with('.')
898                {
899                    continue;
900                }
901                stack.push(path);
902                continue;
903            }
904            if name != manifest_name {
905                continue;
906            }
907            let abs = path;
908            let rel = collapse_project_path(project_root, &abs.to_string_lossy());
909            if !seen_manifests.insert(rel.clone()) {
910                continue;
911            }
912            let Some(package_name) = read_publish_package_name(provider, &abs) else {
913                continue;
914            };
915            let working_directory = abs
916                .parent()
917                .map(|p| collapse_project_path(project_root, &p.to_string_lossy()))
918                .unwrap_or_else(|| "./".to_string());
919            let working_directory = if working_directory == "./" {
920                ".".to_string()
921            } else {
922                working_directory.trim_start_matches("./").to_string()
923            };
924            out.push(DiscoveredPublishPackage {
925                package_name,
926                manifest_path: rel.trim_start_matches("./").to_string(),
927                working_directory,
928            });
929        }
930    }
931
932    out.sort_by(|a, b| a.package_name.cmp(&b.package_name));
933    out
934}
935
936fn read_publish_package_name(provider: &str, manifest: &Path) -> Option<String> {
937    let content = fs::read_to_string(manifest).ok()?;
938    match provider {
939        "npm" => {
940            let value: serde_json::Value = serde_json::from_str(&content).ok()?;
941            // Skip private workspace roots without a real package name.
942            if value.get("private").and_then(|v| v.as_bool()) == Some(true) {
943                // Still allow private packages that have a name (scoped libs).
944            }
945            value
946                .get("name")
947                .and_then(|v| v.as_str())
948                .map(str::trim)
949                .filter(|s| !s.is_empty())
950                .map(str::to_string)
951        }
952        "crates" => {
953            let value: toml::Value = toml::from_str(&content).ok()?;
954            // Skip pure virtual workspaces (no [package]).
955            value
956                .get("package")
957                .and_then(|p| p.get("name"))
958                .and_then(|v| v.as_str())
959                .map(str::trim)
960                .filter(|s| !s.is_empty())
961                .map(str::to_string)
962        }
963        _ => None,
964    }
965}
966
967fn pad_col(s: &str, width: usize) -> String {
968    let len = s.chars().count();
969    if len >= width {
970        s.chars().take(width).collect()
971    } else {
972        format!("{s}{}", " ".repeat(width - len))
973    }
974}
975
976/// Best-effort verification that a registry token is accepted.
977///
978/// Returns optional detail (e.g. npm username) on success.
979async fn verify_registry_token(
980    provider: &str,
981    project_root: &Path,
982    token: &str,
983) -> Result<Option<String>, String> {
984    let token = token.trim();
985    if token.is_empty() {
986        return Err("empty token".into());
987    }
988    match provider {
989        "npm" => verify_npm_token(project_root, token).await,
990        "crates" => verify_crates_token(token).await,
991        _ => Ok(None),
992    }
993}
994
995async fn verify_npm_token(project_root: &Path, token: &str) -> Result<Option<String>, String> {
996    let mut cmd = tokio::process::Command::new("npm");
997    cmd.args(["whoami", "--registry", "https://registry.npmjs.org/"])
998        .current_dir(project_root)
999        .env("NPM_TOKEN", token)
1000        .env("NODE_AUTH_TOKEN", token)
1001        .env("npm_config_//registry.npmjs.org/:_authToken", token)
1002        .stdin(Stdio::null())
1003        .stdout(Stdio::piped())
1004        .stderr(Stdio::piped());
1005    let output = cmd
1006        .output()
1007        .await
1008        .map_err(|e| format!("failed to run `npm whoami`: {e}"))?;
1009    if !output.status.success() {
1010        let err = String::from_utf8_lossy(&output.stderr);
1011        let out = String::from_utf8_lossy(&output.stdout);
1012        let detail = [err.trim(), out.trim()]
1013            .into_iter()
1014            .find(|s| !s.is_empty())
1015            .unwrap_or("npm whoami failed");
1016        return Err(detail.to_string());
1017    }
1018    let user = String::from_utf8_lossy(&output.stdout).trim().to_string();
1019    if user.is_empty() {
1020        return Err("npm whoami returned empty username".into());
1021    }
1022    Ok(Some(user))
1023}
1024
1025async fn verify_crates_token(token: &str) -> Result<Option<String>, String> {
1026    // crates.io accepts the API token as the Authorization value (with or without
1027    // a `Bearer ` prefix). Strip accidental whitespace / Bearer so copy-paste works.
1028    let token = token
1029        .trim()
1030        .strip_prefix("Bearer ")
1031        .or_else(|| token.trim().strip_prefix("bearer "))
1032        .unwrap_or(token.trim());
1033    let client = reqwest::Client::builder()
1034        .user_agent("xbp-init")
1035        .timeout(std::time::Duration::from_secs(15))
1036        .build()
1037        .map_err(|e| e.to_string())?;
1038    let resp = client
1039        .get("https://crates.io/api/v1/me")
1040        .header("Authorization", token)
1041        .send()
1042        .await
1043        .map_err(|e| format!("crates.io /me request failed: {e}"))?;
1044    let status = resp.status();
1045    if !status.is_success() {
1046        return Err(format!("crates.io /me returned HTTP {status}"));
1047    }
1048    let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
1049    let login = body
1050        .pointer("/user/login")
1051        .and_then(|v| v.as_str())
1052        .or_else(|| body.pointer("/user/name").and_then(|v| v.as_str()))
1053        .map(str::to_string);
1054    Ok(login)
1055}
1056
1057/// Ensure project-local dotenv secret files are listed in `.gitignore`.
1058fn ensure_dotenv_gitignored(project_root: &Path) -> Result<(), String> {
1059    let gitignore = project_root.join(".gitignore");
1060    let required = [".env", ".env.local", ".env.*.local"];
1061    let existing = if gitignore.exists() {
1062        fs::read_to_string(&gitignore)
1063            .map_err(|e| format!("Failed to read {}: {e}", gitignore.display()))?
1064    } else {
1065        String::new()
1066    };
1067    let lines: BTreeSet<String> = existing
1068        .lines()
1069        .map(|l| l.trim().to_string())
1070        .filter(|l| !l.is_empty())
1071        .collect();
1072    let mut missing: Vec<&str> = required
1073        .iter()
1074        .copied()
1075        .filter(|pat| {
1076            !lines.iter().any(|line| {
1077                line == pat
1078                    || line == &format!("/{pat}")
1079                    || line == &format!("**/{pat}")
1080                    || line.strip_prefix('/') == Some(*pat)
1081            })
1082        })
1083        .collect();
1084    if missing.is_empty() {
1085        return Ok(());
1086    }
1087    missing.sort();
1088    let listed = missing.join(", ");
1089    let mut out = existing;
1090    if !out.is_empty() && !out.ends_with('\n') {
1091        out.push('\n');
1092    }
1093    if !out.contains("# xbp secrets") {
1094        out.push_str("\n# xbp secrets (local only — never commit)\n");
1095    }
1096    for pat in &missing {
1097        out.push_str(pat);
1098        out.push('\n');
1099    }
1100    fs::write(&gitignore, out)
1101        .map_err(|e| format!("Failed to update {}: {e}", gitignore.display()))?;
1102    println!(
1103        "  {} updated .gitignore for local dotenv secrets ({})",
1104        "✓".bright_green().bold(),
1105        listed.bright_white()
1106    );
1107    Ok(())
1108}
1109
1110fn setup_cloudflare_workers(
1111    project_root: &Path,
1112    config: &mut XbpConfig,
1113    theme: &ColorfulTheme,
1114    notes: &mut Vec<String>,
1115) -> Result<(), String> {
1116    let config_path = project_config_display_path(project_root);
1117    let enable = Confirm::with_theme(theme)
1118        .with_prompt(format!(
1119            "Register Cloudflare Workers apps in {config_path}?"
1120        ))
1121        .default(true)
1122        .interact()
1123        .map_err(|e| e.to_string())?;
1124    if !enable {
1125        return Ok(());
1126    }
1127
1128    let mut workers = config.workers.clone().unwrap_or_default();
1129    let discovered = discover_worker_roots(project_root);
1130    if !discovered.is_empty() {
1131        println!("  {}", "Found Worker-like roots:".bright_blue().bold());
1132        for d in &discovered {
1133            let rel = collapse_project_path(project_root, &d.to_string_lossy());
1134            let display = if rel == "./" {
1135                ".".to_string()
1136            } else {
1137                rel.trim_start_matches("./").to_string()
1138            };
1139            println!("    {} {}", "•".bright_cyan(), display.bright_white());
1140        }
1141        let use_all = Confirm::with_theme(theme)
1142            .with_prompt("Register all discovered Worker roots?")
1143            .default(true)
1144            .interact()
1145            .map_err(|e| e.to_string())?;
1146        if use_all {
1147            for root in discovered {
1148                let name = root
1149                    .file_name()
1150                    .and_then(|s| s.to_str())
1151                    .unwrap_or("worker")
1152                    .to_string();
1153                let rel = collapse_project_path(project_root, &root.to_string_lossy());
1154                if workers.iter().any(|w| w.root == rel || w.name == name) {
1155                    continue;
1156                }
1157                workers.push(WorkerConfig {
1158                    name: name.clone(),
1159                    root: rel,
1160                    script_name: Some(name),
1161                    service: None,
1162                    deploy: Some(WorkerDeployConfig {
1163                        ..Default::default()
1164                    }),
1165                    container: None,
1166                    containers: None,
1167                    durable_objects: None,
1168                });
1169            }
1170        }
1171    }
1172
1173    let add_manual = Confirm::with_theme(theme)
1174        .with_prompt("Add a Worker manually?")
1175        .default(workers.is_empty())
1176        .interact()
1177        .map_err(|e| e.to_string())?;
1178    if add_manual {
1179        let name: String = Input::with_theme(theme)
1180            .with_prompt("Worker name")
1181            .with_initial_text("web")
1182            .interact_text()
1183            .map_err(|e| e.to_string())?;
1184        let name = name.trim().to_string();
1185        let suggested_root = default_worker_root_for_name(&name);
1186        let root: String = Input::with_theme(theme)
1187            .with_prompt("Worker root (repo-relative)")
1188            .with_initial_text(&suggested_root)
1189            .interact_text()
1190            .map_err(|e| e.to_string())?;
1191        workers.push(WorkerConfig {
1192            name: name.clone(),
1193            root: root.trim().to_string(),
1194            script_name: Some(name),
1195            service: None,
1196            deploy: Some(WorkerDeployConfig::default()),
1197            container: None,
1198            containers: None,
1199            durable_objects: None,
1200        });
1201    }
1202
1203    if workers.is_empty() {
1204        notes.push("no workers registered — run `xbp workers deploy configure` later".into());
1205    } else {
1206        config.workers = Some(workers);
1207        notes.push(format!("workers[] registered in {config_path}"));
1208        println!(
1209            "  {}",
1210            "Tip: configure credentials with `xbp config cloudflare setup`".dimmed()
1211        );
1212    }
1213    Ok(())
1214}
1215
1216/// Default repo-relative Worker root derived from the worker name (`apps/{name}`).
1217///
1218/// Sanitizes path-hostile characters so a typed name stays a single path segment.
1219pub fn default_worker_root_for_name(name: &str) -> String {
1220    let segment = name
1221        .trim()
1222        .trim_matches(|c| c == '/' || c == '\\')
1223        .chars()
1224        .map(|c| match c {
1225            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | ' ' => '-',
1226            c => c,
1227        })
1228        .collect::<String>()
1229        .trim_matches('-')
1230        .to_string();
1231    let segment = if segment.is_empty() {
1232        "web".to_string()
1233    } else {
1234        segment
1235    };
1236    format!("apps/{segment}")
1237}
1238
1239fn discover_worker_roots(project_root: &Path) -> Vec<PathBuf> {
1240    let mut out = Vec::new();
1241    let apps = project_root.join("apps");
1242    if apps.is_dir() {
1243        if let Ok(rd) = fs::read_dir(&apps) {
1244            for entry in rd.flatten() {
1245                let path = entry.path();
1246                if !path.is_dir() {
1247                    continue;
1248                }
1249                let has_wrangler = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]
1250                    .iter()
1251                    .any(|f| path.join(f).exists());
1252                if has_wrangler {
1253                    out.push(path);
1254                }
1255            }
1256        }
1257    }
1258    if project_root.join("wrangler.toml").exists() || project_root.join("wrangler.jsonc").exists() {
1259        out.push(project_root.to_path_buf());
1260    }
1261    out.sort();
1262    out.dedup();
1263    out
1264}
1265
1266/// Extract the GitHub owner (user/org) from an `owner/repo` string.
1267pub fn github_owner_from_repository(repository: &str) -> Option<String> {
1268    let repo = repository.trim().trim_matches('/');
1269    if repo.is_empty() {
1270        return None;
1271    }
1272    let owner = repo.split('/').next()?.trim();
1273    if owner.is_empty() || owner.contains(':') {
1274        return None;
1275    }
1276    // Reject bare single-segment strings that are not owner/repo.
1277    if !repo.contains('/') {
1278        return None;
1279    }
1280    Some(owner.to_string())
1281}
1282
1283/// Suggest a GHCR/OCI registry username from project context (pure; unit-tested).
1284///
1285/// Preference order:
1286/// 1. Existing `oci.registries.*.username` (any non-empty)
1287/// 2. Owner from `github.repository` (set earlier in the wizard)
1288/// 3. Owner from a detected `owner/repo` (git remote)
1289pub fn suggest_oci_registry_username(
1290    existing_oci_username: Option<&str>,
1291    github_repository: Option<&str>,
1292    detected_repository: Option<&str>,
1293) -> Option<String> {
1294    if let Some(user) = existing_oci_username.map(str::trim).filter(|s| !s.is_empty()) {
1295        return Some(user.to_string());
1296    }
1297    github_repository
1298        .and_then(github_owner_from_repository)
1299        .or_else(|| detected_repository.and_then(github_owner_from_repository))
1300}
1301
1302fn setup_oci(
1303    project_root: &Path,
1304    config: &mut XbpConfig,
1305    theme: &ColorfulTheme,
1306    notes: &mut Vec<String>,
1307) -> Result<(), String> {
1308    let enable = Confirm::with_theme(theme)
1309        .with_prompt("Configure an OCI registry (e.g. ghcr.io)?")
1310        .default(true)
1311        .interact()
1312        .map_err(|e| e.to_string())?;
1313    if !enable {
1314        return Ok(());
1315    }
1316    let default_registry = config
1317        .oci
1318        .as_ref()
1319        .and_then(|o| o.default_registry.clone())
1320        .filter(|s| !s.trim().is_empty())
1321        .unwrap_or_else(|| "ghcr.io".to_string());
1322    let registry: String = Input::with_theme(theme)
1323        .with_prompt("Registry host")
1324        .with_initial_text(default_registry)
1325        .interact_text()
1326        .map_err(|e| e.to_string())?;
1327
1328    let existing_oci_user = config.oci.as_ref().and_then(|oci| {
1329        oci.registries
1330            .values()
1331            .find_map(|r| r.username.as_deref().map(str::trim).filter(|s| !s.is_empty()))
1332            .map(|s| s.to_string())
1333    });
1334    let github_repo = config
1335        .github
1336        .as_ref()
1337        .and_then(|g| g.repository.as_deref())
1338        .map(str::trim)
1339        .filter(|s| !s.is_empty());
1340    let detected_repo = detect_github_repository(project_root);
1341    let suggested_user = suggest_oci_registry_username(
1342        existing_oci_user.as_deref(),
1343        github_repo,
1344        detected_repo.as_deref(),
1345    )
1346    .unwrap_or_default();
1347
1348    let username: String = Input::with_theme(theme)
1349        .with_prompt("Registry username / GitHub org (package owner for image paths)")
1350        .with_initial_text(suggested_user)
1351        .allow_empty(true)
1352        .interact_text()
1353        .map_err(|e| e.to_string())?;
1354
1355    let default_package_repo = config
1356        .oci
1357        .as_ref()
1358        .and_then(|o| o.repository.clone())
1359        .or_else(|| {
1360            let owner = username.trim();
1361            if owner.is_empty() {
1362                None
1363            } else {
1364                // Optional dedicated package repo under the same owner (may differ from source).
1365                github_repo.map(|src| {
1366                    // Prefer existing source repo as default package link target.
1367                    src.to_string()
1368                })
1369            }
1370        })
1371        .or_else(|| detected_repo.clone())
1372        .unwrap_or_default();
1373
1374    let package_repo: String = Input::with_theme(theme)
1375        .with_prompt(
1376            "GitHub repository for OCI packages (owner/repo; may differ from source repo)",
1377        )
1378        .with_initial_text(default_package_repo)
1379        .allow_empty(true)
1380        .interact_text()
1381        .map_err(|e| e.to_string())?;
1382
1383    let token_source: String = Input::with_theme(theme)
1384        .with_prompt("Token env placeholder")
1385        .with_initial_text("${GITHUB_TOKEN}")
1386        .interact_text()
1387        .map_err(|e| e.to_string())?;
1388
1389    let owner = {
1390        let u = username.trim();
1391        if !u.is_empty() {
1392            Some(u.to_string())
1393        } else {
1394            package_repo
1395                .trim()
1396                .split_once('/')
1397                .map(|(o, _)| o.trim().to_string())
1398                .filter(|s| !s.is_empty())
1399        }
1400    };
1401
1402    let mut registries = HashMap::new();
1403    registries.insert(
1404        registry.trim().to_string(),
1405        OciRegistryConfig {
1406            provider: Some("github".into()),
1407            username: owner.clone(),
1408            token_source: Some(token_source.trim().to_string()),
1409            url: None,
1410        },
1411    );
1412    config.oci = Some(OciProjectConfig {
1413        registries,
1414        default_registry: Some(registry.trim().to_string()),
1415        owner: owner.clone(),
1416        repository: {
1417            let r = package_repo.trim();
1418            (!r.is_empty()).then(|| r.to_string())
1419        },
1420    });
1421    notes.push(format!("oci.default_registry = {}", registry.trim()));
1422    if let Some(o) = owner {
1423        notes.push(format!("oci.owner = {o}"));
1424    }
1425    if let Some(r) = config.oci.as_ref().and_then(|o| o.repository.as_deref()) {
1426        notes.push(format!("oci.repository = {r}"));
1427    }
1428    Ok(())
1429}
1430
1431fn setup_kubernetes(
1432    config: &mut XbpConfig,
1433    theme: &ColorfulTheme,
1434    notes: &mut Vec<String>,
1435) -> Result<(), String> {
1436    let enable = Confirm::with_theme(theme)
1437        .with_prompt("Configure Kubernetes deploy defaults?")
1438        .default(true)
1439        .interact()
1440        .map_err(|e| e.to_string())?;
1441    if !enable {
1442        return Ok(());
1443    }
1444    let namespace: String = Input::with_theme(theme)
1445        .with_prompt("Default namespace")
1446        .with_initial_text("default")
1447        .interact_text()
1448        .map_err(|e| e.to_string())?;
1449    let context: String = Input::with_theme(theme)
1450        .with_prompt("Default kubectl context (empty = current)")
1451        .allow_empty(true)
1452        .interact_text()
1453        .map_err(|e| e.to_string())?;
1454    let require_confirm = Confirm::with_theme(theme)
1455        .with_prompt("Require context confirmation before apply?")
1456        .default(true)
1457        .interact()
1458        .map_err(|e| e.to_string())?;
1459
1460    config.kubernetes = Some(KubernetesProjectConfig {
1461        default_context: Some(context.trim().to_string()).filter(|s| !s.is_empty()),
1462        default_namespace: Some(namespace.trim().to_string()),
1463        require_context_confirmation_for_apply: Some(require_confirm),
1464        kubeconfig: None,
1465    });
1466
1467    // Not every service should land on k8s — multi-select which ones get deploy.envs.
1468    let env_name = config
1469        .deploy
1470        .as_ref()
1471        .and_then(|d| d.default_env.clone())
1472        .filter(|s| !s.trim().is_empty())
1473        .unwrap_or_else(|| "production".to_string());
1474    let selected = multi_select_services_for_deploy(
1475        config,
1476        theme,
1477        "Kubernetes",
1478        &format!(
1479            "Which services should get deploy.envs.{env_name} (kubernetes)? Not all monorepo packages need a cluster workload."
1480        ),
1481        /* default_selected */ false,
1482    )?;
1483    if !selected.is_empty() {
1484        apply_k8s_deploy_env_to_services(config, &selected, &env_name, namespace.trim());
1485        notes.push(format!(
1486            "kubernetes deploy.envs.{env_name} on {} service(s)",
1487            selected.len()
1488        ));
1489    } else {
1490        notes.push(
1491            "kubernetes defaults saved (no services selected — set deploy.envs later)"
1492                .into(),
1493        );
1494    }
1495    Ok(())
1496}
1497
1498fn setup_deploy_defaults(
1499    config: &mut XbpConfig,
1500    theme: &ColorfulTheme,
1501    notes: &mut Vec<String>,
1502) -> Result<(), String> {
1503    let default_env: String = Input::with_theme(theme)
1504        .with_prompt("Default deploy environment name")
1505        .with_initial_text("production")
1506        .interact_text()
1507        .map_err(|e| e.to_string())?;
1508
1509    let service_names: Vec<String> = config
1510        .services
1511        .as_ref()
1512        .map(|s| s.iter().map(|svc| svc.name.clone()).collect())
1513        .unwrap_or_default();
1514
1515    let mut groups = HashMap::new();
1516    if !service_names.is_empty() {
1517        print_picker_header(
1518            "xbp deploy  ·  groups",
1519            &format!(
1520                "{} discovered service(s). Multi-select which belong in deployable groups — skip libs, examples, and tooling.",
1521                service_names.len()
1522            ),
1523        );
1524
1525        let selected = multi_select_services_for_deploy(
1526            config,
1527            theme,
1528            "deploy group",
1529            "Services to include in deploy groups (space toggles). Leave none selected to skip groups.",
1530            /* default_selected */ false,
1531        )?;
1532
1533        if !selected.is_empty() {
1534            let create_all = Confirm::with_theme(theme)
1535                .with_prompt(format!(
1536                    "Create deploy group `all` with the {} selected service(s)?",
1537                    selected.len()
1538                ))
1539                .default(true)
1540                .interact()
1541                .map_err(|e| e.to_string())?;
1542            if create_all {
1543                groups.insert(
1544                    "all".to_string(),
1545                    crate::strategies::deployment_config::DeployGroupConfig {
1546                        services: selected.clone(),
1547                        order: selected.clone(),
1548                        description: Some("Selected deployable services".into()),
1549                    },
1550                );
1551            }
1552
1553            // Optional second named group for a smaller core set.
1554            if selected.len() > 3 {
1555                let make_core = Confirm::with_theme(theme)
1556                    .with_prompt("Also create a smaller `core` group from a subset?")
1557                    .default(false)
1558                    .interact()
1559                    .map_err(|e| e.to_string())?;
1560                if make_core {
1561                    let core = multi_select_from_names(
1562                        theme,
1563                        &selected,
1564                        "core group services",
1565                        "Subset for deploy group `core`",
1566                        false,
1567                    )?;
1568                    if !core.is_empty() {
1569                        groups.insert(
1570                            "core".to_string(),
1571                            crate::strategies::deployment_config::DeployGroupConfig {
1572                                services: core.clone(),
1573                                order: core,
1574                                description: Some("Core deployable services".into()),
1575                            },
1576                        );
1577                    }
1578                }
1579            }
1580            notes.push(format!(
1581                "deploy groups: {} ({} service(s) selected)",
1582                groups.keys().cloned().collect::<Vec<_>>().join(", "),
1583                selected.len()
1584            ));
1585        } else {
1586            notes.push("deploy groups skipped (no services selected)".into());
1587        }
1588    }
1589
1590    config.deploy = Some(DeployProjectConfig {
1591        default_env: Some(default_env.trim().to_string()),
1592        history_dir: Some(".xbp/deployments".into()),
1593        lock_file: Some(".xbp/deploy.lock".into()),
1594        groups,
1595    });
1596    notes.push(format!(
1597        "deploy.default_env = {}",
1598        default_env.trim()
1599    ));
1600    Ok(())
1601}
1602
1603/// Multi-select over `services[]` for deploy/k8s inclusion.
1604///
1605/// Defaults off for large monorepos so operators opt-in instead of shipping
1606/// every package to the cluster.
1607fn multi_select_services_for_deploy(
1608    config: &XbpConfig,
1609    theme: &ColorfulTheme,
1610    kind_label: &str,
1611    subtitle: &str,
1612    default_selected: bool,
1613) -> Result<Vec<String>, String> {
1614    let services = config.services.as_deref().unwrap_or(&[]);
1615    if services.is_empty() {
1616        return Ok(Vec::new());
1617    }
1618    let labels: Vec<String> = services
1619        .iter()
1620        .map(|s| {
1621            let provider = s
1622                .deploy
1623                .as_ref()
1624                .and_then(|d| d.provider.as_deref())
1625                .unwrap_or(s.target.as_str());
1626            format!(
1627                "{}  ·  {}  ·  :{}",
1628                pad_col(&s.name, 36),
1629                pad_col(provider, 14),
1630                s.port
1631            )
1632        })
1633        .collect();
1634    let names: Vec<String> = services.iter().map(|s| s.name.clone()).collect();
1635    print_picker_header(
1636        &format!("xbp init  ·  {kind_label} services"),
1637        subtitle,
1638    );
1639    multi_select_labeled(theme, &names, &labels, &format!("{kind_label} services"), default_selected)
1640}
1641
1642fn multi_select_from_names(
1643    theme: &ColorfulTheme,
1644    names: &[String],
1645    prompt: &str,
1646    subtitle: &str,
1647    default_selected: bool,
1648) -> Result<Vec<String>, String> {
1649    if !subtitle.is_empty() {
1650        println!("  {}", subtitle.bright_black());
1651    }
1652    multi_select_labeled(theme, names, names, prompt, default_selected)
1653}
1654
1655fn multi_select_labeled(
1656    theme: &ColorfulTheme,
1657    names: &[String],
1658    labels: &[String],
1659    prompt: &str,
1660    default_selected: bool,
1661) -> Result<Vec<String>, String> {
1662    if names.is_empty() {
1663        return Ok(Vec::new());
1664    }
1665    debug_assert_eq!(names.len(), labels.len());
1666    let defaults: Vec<bool> = names.iter().map(|_| default_selected).collect();
1667    let chosen = MultiSelect::with_theme(theme)
1668        .with_prompt(prompt)
1669        .items(labels)
1670        .defaults(&defaults)
1671        .interact()
1672        .map_err(|e| e.to_string())?;
1673    Ok(chosen.into_iter().map(|i| names[i].clone()).collect())
1674}
1675
1676fn apply_k8s_deploy_env_to_services(
1677    config: &mut XbpConfig,
1678    service_names: &[String],
1679    env_name: &str,
1680    namespace: &str,
1681) {
1682    use crate::strategies::deployment_config::{
1683        ServiceDeployConfig, ServiceDeployEnvConfig, ServiceDeployKubernetesConfig,
1684    };
1685    let Some(services) = config.services.as_mut() else {
1686        return;
1687    };
1688    let selected: BTreeSet<&str> = service_names.iter().map(|s| s.as_str()).collect();
1689    for service in services.iter_mut() {
1690        if !selected.contains(service.name.as_str()) {
1691            continue;
1692        }
1693        let deploy = service.deploy.get_or_insert_with(ServiceDeployConfig::default);
1694        if deploy.provider.is_none() {
1695            deploy.provider = Some("kubernetes".into());
1696        }
1697        deploy.envs.entry(env_name.to_string()).or_insert_with(|| {
1698            ServiceDeployEnvConfig {
1699                namespace: Some(namespace.to_string()).filter(|s| !s.is_empty()),
1700                replicas: Some(1),
1701                health: Vec::new(),
1702                kubernetes: Some(ServiceDeployKubernetesConfig {
1703                    workload: Some(format!("deploy/{}", service.name)),
1704                    service: Some(service.name.clone()),
1705                    selector: Some(format!("app={}", service.name)),
1706                    ..Default::default()
1707                }),
1708                ..Default::default()
1709            }
1710        });
1711    }
1712}
1713
1714const DISCORD_WEBHOOK_ENV_KEYS: &[&str] = &["DISCORD_WEBHOOK_URL"];
1715
1716/// Where an existing Discord webhook URL was found (for wizard messaging).
1717#[derive(Debug, Clone, PartialEq, Eq)]
1718pub enum DiscordWebhookEnvSource {
1719    /// Value loaded from a project dotenv file (`path` is absolute or relative).
1720    Dotenv { path: PathBuf },
1721    /// Value present in the process environment.
1722    ProcessEnv,
1723}
1724
1725/// Locate an existing `DISCORD_WEBHOOK_URL` without printing secrets in full.
1726///
1727/// Preference: project dotenv files (walked upwards), then process env.
1728pub fn find_existing_discord_webhook_url(
1729    project_root: &Path,
1730) -> Option<(String, DiscordWebhookEnvSource)> {
1731    if let Some((value, path)) =
1732        first_dotenv_value_upwards(project_root, DISCORD_WEBHOOK_ENV_KEYS, 8)
1733    {
1734        let trimmed = value.trim();
1735        if !trimmed.is_empty() {
1736            return Some((
1737                trimmed.to_string(),
1738                DiscordWebhookEnvSource::Dotenv { path },
1739            ));
1740        }
1741    }
1742    if let Ok(value) = std::env::var("DISCORD_WEBHOOK_URL") {
1743        let trimmed = value.trim();
1744        if !trimmed.is_empty() {
1745            return Some((trimmed.to_string(), DiscordWebhookEnvSource::ProcessEnv));
1746        }
1747    }
1748    None
1749}
1750
1751/// Mask a webhook URL for display (same style as config secret masking).
1752pub fn mask_discord_webhook_url(url: &str) -> String {
1753    let chars: Vec<char> = url.chars().collect();
1754    if chars.is_empty() {
1755        return "(empty)".to_string();
1756    }
1757    if chars.len() <= 8 {
1758        return "*".repeat(chars.len());
1759    }
1760    let prefix: String = chars.iter().take(4).collect();
1761    let suffix: String = chars.iter().skip(chars.len() - 4).collect();
1762    format!("{prefix}...{suffix}")
1763}
1764
1765fn format_discord_webhook_env_source(
1766    project_root: &Path,
1767    source: &DiscordWebhookEnvSource,
1768) -> String {
1769    match source {
1770        DiscordWebhookEnvSource::Dotenv { path } => {
1771            collapse_project_path(project_root, &path.to_string_lossy())
1772        }
1773        DiscordWebhookEnvSource::ProcessEnv => "process environment".to_string(),
1774    }
1775}
1776
1777fn setup_discord(
1778    project_root: &Path,
1779    config: &mut XbpConfig,
1780    theme: &ColorfulTheme,
1781    notes: &mut Vec<String>,
1782) -> Result<(), String> {
1783    let enable = Confirm::with_theme(theme)
1784        .with_prompt("Configure Discord release/deploy webhooks?")
1785        .default(true)
1786        .interact()
1787        .map_err(|e| e.to_string())?;
1788    if !enable {
1789        return Ok(());
1790    }
1791
1792    let config_path = project_config_display_path(project_root);
1793    let env_path = preferred_local_secret_env_path(project_root);
1794    let env_label = collapse_project_path(project_root, &env_path.to_string_lossy());
1795    let existing_webhook = find_existing_discord_webhook_url(project_root);
1796
1797    if let Some((url, source)) = existing_webhook.as_ref() {
1798        let where_found = format_discord_webhook_env_source(project_root, source);
1799        println!(
1800            "  {} {} already set in {} ({})",
1801            "✓".green(),
1802            "DISCORD_WEBHOOK_URL".bright_white(),
1803            where_found.bright_cyan(),
1804            mask_discord_webhook_url(url).dimmed()
1805        );
1806    }
1807
1808    let env_mode_label = if existing_webhook.is_some() {
1809        let where_found = existing_webhook
1810            .as_ref()
1811            .map(|(_, s)| format_discord_webhook_env_source(project_root, s))
1812            .unwrap_or_else(|| env_label.clone());
1813        format!("Env placeholder ${{DISCORD_WEBHOOK_URL}} (already set in {where_found})")
1814    } else {
1815        format!("Env placeholder ${{DISCORD_WEBHOOK_URL}} (writes {env_label})")
1816    };
1817    let modes = vec![
1818        format!("Project webhook URL (stored in {config_path})"),
1819        env_mode_label,
1820        "Skip (use `xbp config discord setup` later)".to_string(),
1821    ];
1822    let idx = Select::with_theme(theme)
1823        .with_prompt("Webhook configuration mode")
1824        .items(&modes)
1825        .default(1)
1826        .interact()
1827        .map_err(|e| e.to_string())?;
1828
1829    let webhook_url = match idx {
1830        0 => {
1831            let url: String = Input::with_theme(theme)
1832                .with_prompt("Discord webhook URL")
1833                .interact_text()
1834                .map_err(|e| e.to_string())?;
1835            let url = url.trim().to_string();
1836            if url.is_empty() {
1837                return Err("Discord webhook URL cannot be empty.".to_string());
1838            }
1839            Some(url)
1840        }
1841        1 => {
1842            let keep_existing = if let Some((url, source)) = existing_webhook.as_ref() {
1843                let where_found = format_discord_webhook_env_source(project_root, source);
1844                Confirm::with_theme(theme)
1845                    .with_prompt(format!(
1846                        "Keep existing DISCORD_WEBHOOK_URL from {where_found} ({})?",
1847                        mask_discord_webhook_url(url)
1848                    ))
1849                    .default(true)
1850                    .interact()
1851                    .map_err(|e| e.to_string())?
1852            } else {
1853                false
1854            };
1855
1856            if keep_existing {
1857                notes.push(format!(
1858                    "DISCORD_WEBHOOK_URL already set ({}); left unchanged",
1859                    existing_webhook
1860                        .as_ref()
1861                        .map(|(_, s)| format_discord_webhook_env_source(project_root, s))
1862                        .unwrap_or_else(|| env_label.clone())
1863                ));
1864            } else {
1865                let url: String = Input::with_theme(theme)
1866                    .with_prompt("Discord webhook URL (saved to env file)")
1867                    .interact_text()
1868                    .map_err(|e| e.to_string())?;
1869                let url = url.trim().to_string();
1870                if url.is_empty() {
1871                    return Err("Discord webhook URL cannot be empty.".to_string());
1872                }
1873                // If the value only lived in process env, still persist it to the project env file.
1874                upsert_env_key(&env_path, "DISCORD_WEBHOOK_URL", &url)?;
1875                notes.push(format!("DISCORD_WEBHOOK_URL written to {env_label}"));
1876            }
1877            Some("${DISCORD_WEBHOOK_URL}".into())
1878        }
1879        _ => None,
1880    };
1881
1882    if let Some(url) = webhook_url {
1883        let username: String = Input::with_theme(theme)
1884            .with_prompt("Webhook display name")
1885            .with_initial_text("XBP")
1886            .interact_text()
1887            .map_err(|e| e.to_string())?;
1888        config.discord = Some(DiscordConfig {
1889            enabled: Some(true),
1890            webhook_url: Some(url),
1891            username: Some(username),
1892            avatar_url: None,
1893            events: Some(DiscordEventsConfig {
1894                version_release: Some(true),
1895                github_tag: Some(false),
1896                github_release: Some(false),
1897                crates_io: Some(true),
1898                npm: Some(true),
1899                linear: Some(true),
1900                cargo_dist: Some(true),
1901                oci: Some(true),
1902                workers_deploy: Some(true),
1903                kubernetes_deploy: Some(true),
1904                deploy: Some(true),
1905                openapi: Some(true),
1906                issues: Some(true),
1907                commit: Some(true),
1908            }),
1909        });
1910        notes.push("discord webhook configured (test with `xbp config discord test`)".into());
1911    } else {
1912        notes.push("discord skipped".into());
1913    }
1914    Ok(())
1915}
1916
1917fn setup_monitoring(
1918    config: &mut XbpConfig,
1919    theme: &ColorfulTheme,
1920    notes: &mut Vec<String>,
1921) -> Result<(), String> {
1922    let enable = Confirm::with_theme(theme)
1923        .with_prompt("Configure a synthetic monitor URL?")
1924        .default(false)
1925        .interact()
1926        .map_err(|e| e.to_string())?;
1927    if !enable {
1928        return Ok(());
1929    }
1930    let url: String = Input::with_theme(theme)
1931        .with_prompt("Monitor URL")
1932        .with_initial_text(
1933            config
1934                .url
1935                .clone()
1936                .unwrap_or_else(|| "https://example.com/health".into()),
1937        )
1938        .interact_text()
1939        .map_err(|e| e.to_string())?;
1940    let method: String = Input::with_theme(theme)
1941        .with_prompt("HTTP method")
1942        .with_initial_text("GET")
1943        .interact_text()
1944        .map_err(|e| e.to_string())?;
1945    let expected: u16 = Input::with_theme(theme)
1946        .with_prompt("Expected status code")
1947        .default(200)
1948        .interact_text()
1949        .map_err(|e| e.to_string())?;
1950    let interval: u64 = Input::with_theme(theme)
1951        .with_prompt("Interval seconds")
1952        .default(60)
1953        .interact_text()
1954        .map_err(|e| e.to_string())?;
1955
1956    config.monitor_url = Some(url.trim().to_string());
1957    config.monitor_method = Some(method.trim().to_ascii_uppercase());
1958    config.monitor_expected_code = Some(expected);
1959    config.monitor_interval = Some(interval);
1960    notes.push("monitor_url configured".into());
1961    Ok(())
1962}
1963
1964async fn setup_github_release(
1965    project_root: &Path,
1966    config: &mut XbpConfig,
1967    theme: &ColorfulTheme,
1968    notes: &mut Vec<String>,
1969) -> Result<(), String> {
1970    println!(
1971        "  {}",
1972        "Controls `xbp version release` GitHub branch creation and auto-push of generated commits."
1973            .bright_black()
1974    );
1975
1976    let detected_repo = detect_github_repository(project_root);
1977    let repository: String = Input::with_theme(theme)
1978        .with_prompt("GitHub repository (owner/name, empty to skip)")
1979        .with_initial_text(detected_repo.unwrap_or_default())
1980        .allow_empty(true)
1981        .interact_text()
1982        .map_err(|e| e.to_string())?;
1983
1984    let auto_push = Confirm::with_theme(theme)
1985        .with_prompt("Auto-push commits created by XBP (config/version changes)?")
1986        .default(true)
1987        .interact()
1988        .map_err(|e| e.to_string())?;
1989
1990    let enable_branch = Confirm::with_theme(theme)
1991        .with_prompt("Create a dedicated GitHub release branch on `xbp version release`?")
1992        .default(true)
1993        .interact()
1994        .map_err(|e| e.to_string())?;
1995
1996    let naming_template = if enable_branch {
1997        println!(
1998            "  {} {}",
1999            "Template tokens:".bright_black(),
2000            GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS.dimmed()
2001        );
2002        let tmpl: String = Input::with_theme(theme)
2003            .with_prompt("Release branch naming template")
2004            .with_initial_text(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
2005            .validate_with(|input: &String| -> Result<(), String> {
2006                validate_github_release_branch_naming_template(input)
2007            })
2008            .interact_text()
2009            .map_err(|e| e.to_string())?;
2010        Some(tmpl)
2011    } else {
2012        None
2013    };
2014
2015    let setup_token = Confirm::with_theme(theme)
2016        .with_prompt("Configure GitHub API token now? (`xbp config github set-key`)")
2017        .default(false)
2018        .interact()
2019        .map_err(|e| e.to_string())?;
2020    if setup_token {
2021        match crate::commands::config_cmd::run_config_secret_set("github", None).await {
2022            Ok(()) => notes.push("github API token stored in global config".into()),
2023            Err(err) => {
2024                println!("  {} {}", "GitHub token:".yellow(), err.dimmed());
2025                notes.push("github token setup failed — run `xbp config github set-key` later".into());
2026            }
2027        }
2028    }
2029
2030    config.github = Some(build_github_release_config(
2031        Some(repository).filter(|s| !s.trim().is_empty()),
2032        auto_push,
2033        enable_branch,
2034        naming_template,
2035    ));
2036
2037    if enable_branch {
2038        notes.push(format!(
2039            "github.release_branch enabled (template: {})",
2040            config
2041                .github
2042                .as_ref()
2043                .and_then(|g| g.release_branch.as_ref())
2044                .and_then(|b| b.naming_template.as_deref())
2045                .unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
2046        ));
2047    } else {
2048        notes.push("github.release_branch disabled".into());
2049    }
2050    if auto_push {
2051        notes.push("github.auto_push_on_commit = true".into());
2052    }
2053    Ok(())
2054}
2055
2056/// Detect `owner/repo` from `git remote get-url origin` when possible.
2057pub fn detect_github_repository(project_root: &Path) -> Option<String> {
2058    let output = std::process::Command::new("git")
2059        .args(["remote", "get-url", "origin"])
2060        .current_dir(project_root)
2061        .output()
2062        .ok()?;
2063    if !output.status.success() {
2064        return None;
2065    }
2066    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
2067    parse_github_owner_repo(&url)
2068}
2069
2070/// Parse `owner/repo` from common GitHub remote URL forms (pure; unit-tested).
2071pub fn parse_github_owner_repo(url: &str) -> Option<String> {
2072    let url = url.trim().trim_end_matches(".git");
2073    if url.is_empty() {
2074        return None;
2075    }
2076    // git@github.com:owner/repo
2077    if let Some(rest) = url.strip_prefix("git@github.com:") {
2078        let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
2079        if parts.len() >= 2 {
2080            return Some(format!("{}/{}", parts[0], parts[1]));
2081        }
2082    }
2083    // https://github.com/owner/repo
2084    for prefix in [
2085        "https://github.com/",
2086        "http://github.com/",
2087        "ssh://git@github.com/",
2088    ] {
2089        if let Some(rest) = url.strip_prefix(prefix) {
2090            let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
2091            if parts.len() >= 2 {
2092                return Some(format!("{}/{}", parts[0], parts[1]));
2093            }
2094        }
2095    }
2096    // already owner/repo
2097    let parts: Vec<&str> = url.split('/').filter(|p| !p.is_empty()).collect();
2098    if parts.len() == 2 && !parts[0].contains(':') {
2099        return Some(format!("{}/{}", parts[0], parts[1]));
2100    }
2101    None
2102}
2103
2104async fn setup_linear_release(
2105    config: &mut XbpConfig,
2106    theme: &ColorfulTheme,
2107    notes: &mut Vec<String>,
2108) -> Result<(), String> {
2109    println!(
2110        "  {}",
2111        "Posts release changelogs / updates to a Linear initiative during `xbp version release`."
2112            .bright_black()
2113    );
2114
2115    let enable = Confirm::with_theme(theme)
2116        .with_prompt("Post release changelogs to Linear?")
2117        .default(true)
2118        .interact()
2119        .map_err(|e| e.to_string())?;
2120
2121    if !enable {
2122        config.linear = Some(build_linear_release_config(false, Vec::new(), None));
2123        notes.push("linear.release.enabled = false".into());
2124        return Ok(());
2125    }
2126
2127    #[cfg(not(feature = "linear"))]
2128    {
2129        println!(
2130            "  {}",
2131            "This CLI binary was built without `--features linear`. Enabling config only; rebuild with linear for initiative picker."
2132                .yellow()
2133        );
2134        let org: String = Input::with_theme(theme)
2135            .with_prompt("Linear organization slug (for initiative URLs, optional)")
2136            .allow_empty(true)
2137            .interact_text()
2138            .map_err(|e| e.to_string())?;
2139        let id: String = Input::with_theme(theme)
2140            .with_prompt("Linear initiative UUID (paste, or leave empty)")
2141            .allow_empty(true)
2142            .interact_text()
2143            .map_err(|e| e.to_string())?;
2144        let mut ids = Vec::new();
2145        if !id.trim().is_empty() {
2146            ids.push(id.trim().to_string());
2147        }
2148        config.linear = Some(build_linear_release_config(
2149            true,
2150            ids,
2151            Some(org).filter(|s| !s.trim().is_empty()),
2152        ));
2153        notes.push(
2154            "linear.release enabled (no interactive picker — binary lacks `linear` feature)".into(),
2155        );
2156        return Ok(());
2157    }
2158
2159    #[cfg(feature = "linear")]
2160    {
2161        // Prefer an already-configured global/project Linear key; only prompt when missing.
2162        let existing_key = resolve_linear_api_key();
2163        let api_key = if let Some(key) = existing_key {
2164            println!(
2165                "  {} Linear API key already configured (global/project)",
2166                "✓".bright_green().bold()
2167            );
2168            Some(key)
2169        } else {
2170            let ensure_key = Confirm::with_theme(theme)
2171                .with_prompt("Linear API key not found — configure one now?")
2172                .default(true)
2173                .interact()
2174                .map_err(|e| e.to_string())?;
2175            if ensure_key {
2176                match crate::commands::linear_cmd::ensure_linear_api_key().await {
2177                    Ok(key) => Some(key),
2178                    Err(err) => {
2179                        println!("  {} {}", "Linear key:".yellow(), err.dimmed());
2180                        None
2181                    }
2182                }
2183            } else {
2184                None
2185            }
2186        };
2187
2188        let org: String = Input::with_theme(theme)
2189            .with_prompt("Linear organization slug (for initiative URLs, optional)")
2190            .allow_empty(true)
2191            .with_initial_text(
2192                config
2193                    .linear
2194                    .as_ref()
2195                    .and_then(|l| l.organization_name.clone())
2196                    .unwrap_or_default(),
2197            )
2198            .interact_text()
2199            .map_err(|e| e.to_string())?;
2200
2201        let mut initiative_ids = Vec::new();
2202        if let Some(api_key) = api_key.as_deref() {
2203            match crate::commands::linear::fetch_available_initiatives(api_key).await {
2204                Ok(initiatives) if !initiatives.is_empty() => {
2205                    let labels: Vec<String> = initiatives
2206                        .iter()
2207                        .map(|i| {
2208                            let status = i.status.clone().unwrap_or_default();
2209                            if status.is_empty() {
2210                                i.name.clone()
2211                            } else {
2212                                format!("{} [{}]", i.name, status)
2213                            }
2214                        })
2215                        .collect();
2216                    print_picker_header(
2217                        "xbp linear  ·  initiative",
2218                        "Release changelogs post here during `xbp version release`",
2219                    );
2220                    let selection = dialoguer::FuzzySelect::with_theme(&xbp_theme())
2221                        .with_prompt("Select Linear initiative for release posts")
2222                        .default(0)
2223                        .items(&labels)
2224                        .interact_opt()
2225                        .map_err(|e| e.to_string())?;
2226                    if let Some(idx) = selection {
2227                        if let Some(init) = initiatives.get(idx) {
2228                            initiative_ids.push(init.id.clone());
2229                            println!(
2230                                "  {} {} ({})",
2231                                "Selected:".green(),
2232                                init.name.bright_white(),
2233                                init.id.dimmed()
2234                            );
2235                        }
2236                    }
2237                }
2238                Ok(_) => {
2239                    println!(
2240                        "  {}",
2241                        "No Linear initiatives found for this API key.".yellow()
2242                    );
2243                }
2244                Err(err) => {
2245                    println!(
2246                        "  {} {}",
2247                        "Could not list initiatives:".yellow(),
2248                        err.dimmed()
2249                    );
2250                }
2251            }
2252        }
2253
2254        if initiative_ids.is_empty() {
2255            let manual: String = Input::with_theme(theme)
2256                .with_prompt("Paste Linear initiative UUID (empty to enable without binding one)")
2257                .allow_empty(true)
2258                .interact_text()
2259                .map_err(|e| e.to_string())?;
2260            if !manual.trim().is_empty() {
2261                initiative_ids.push(manual.trim().to_string());
2262            }
2263        }
2264
2265        // Default team for `xbp linear create` — selector when API key is available.
2266        let team_key = prompt_default_linear_team_key(api_key.as_deref(), theme).await?;
2267
2268        let mut linear = build_linear_release_config(
2269            true,
2270            initiative_ids.clone(),
2271            Some(org.clone()).filter(|s| !s.trim().is_empty()),
2272        );
2273        if !team_key.trim().is_empty() {
2274            linear.default_team_key = Some(team_key.trim().to_string());
2275        }
2276        config.linear = Some(linear);
2277
2278        if initiative_ids.is_empty() {
2279            notes.push(
2280                "linear.release enabled without initiative — run `xbp config linear select-initiative` later"
2281                    .into(),
2282            );
2283        } else {
2284            notes.push(format!(
2285                "linear.release → initiative {}",
2286                initiative_ids.join(", ")
2287            ));
2288        }
2289        if !team_key.trim().is_empty() {
2290            notes.push(format!("linear.default_team_key = {}", team_key.trim()));
2291        }
2292        Ok(())
2293    }
2294}
2295
2296#[cfg(feature = "linear")]
2297async fn prompt_default_linear_team_key(
2298    api_key: Option<&str>,
2299    theme: &ColorfulTheme,
2300) -> Result<String, String> {
2301    if let Some(api_key) = api_key {
2302        match crate::commands::linear_cmd::list_teams(api_key).await {
2303            Ok(teams) if !teams.is_empty() => {
2304                let mut labels: Vec<String> = teams
2305                    .iter()
2306                    .map(|t| format!("{}  —  {}", pad_col(&t.key, 8), t.name))
2307                    .collect();
2308                labels.push("(skip — no default team)".to_string());
2309                print_picker_header(
2310                    "xbp linear  ·  default team",
2311                    "Used by `xbp linear create` when --team is omitted  ·  type to filter",
2312                );
2313                let idx = FuzzySelect::with_theme(&xbp_theme())
2314                    .with_prompt("Default Linear team key")
2315                    .items(&labels)
2316                    .default(0)
2317                    .interact_opt()
2318                    .map_err(|e| e.to_string())?;
2319                return Ok(match idx {
2320                    Some(i) if i < teams.len() => teams[i].key.clone(),
2321                    _ => String::new(),
2322                });
2323            }
2324            Ok(_) => {
2325                println!(
2326                    "  {}",
2327                    "No Linear teams found for this API key.".yellow()
2328                );
2329            }
2330            Err(err) => {
2331                println!(
2332                    "  {} {}",
2333                    "Could not list Linear teams:".yellow(),
2334                    err.dimmed()
2335                );
2336            }
2337        }
2338    }
2339
2340    Input::with_theme(theme)
2341        .with_prompt("Default Linear team key (e.g. XLX, empty to skip)")
2342        .allow_empty(true)
2343        .interact_text()
2344        .map_err(|e| e.to_string())
2345}
2346
2347#[cfg(test)]
2348fn split_csv(raw: &str) -> Vec<String> {
2349    raw.split(',')
2350        .map(|s| s.trim().to_string())
2351        .filter(|s| !s.is_empty())
2352        .collect()
2353}
2354
2355#[cfg(test)]
2356mod tests {
2357    use super::*;
2358    use std::fs;
2359    use std::time::{SystemTime, UNIX_EPOCH};
2360
2361    fn temp_dir(label: &str) -> PathBuf {
2362        let nanos = SystemTime::now()
2363            .duration_since(UNIX_EPOCH)
2364            .map(|d| d.as_nanos())
2365            .unwrap_or(0);
2366        let dir = std::env::temp_dir().join(format!("xbp-init-wizard-{label}-{nanos}"));
2367        fs::create_dir_all(&dir).expect("temp dir");
2368        dir
2369    }
2370
2371    #[test]
2372    fn default_modules_include_cargo_and_workers_when_markers_present() {
2373        let mods = default_modules_for_project(true, true, true, true, true);
2374        assert!(mods.contains(&InitSetupModule::CargoDist));
2375        assert!(mods.contains(&InitSetupModule::PublishCrates));
2376        assert!(mods.contains(&InitSetupModule::PublishNpm));
2377        assert!(mods.contains(&InitSetupModule::CloudflareWorkers));
2378        assert!(mods.contains(&InitSetupModule::OciRegistry));
2379        assert!(mods.contains(&InitSetupModule::Kubernetes));
2380        assert!(mods.contains(&InitSetupModule::Discord));
2381    }
2382
2383    #[test]
2384    fn default_modules_minimal_without_markers() {
2385        let mods = default_modules_for_project(false, false, false, false, false);
2386        assert!(mods.contains(&InitSetupModule::VersionTargets));
2387        assert!(mods.contains(&InitSetupModule::Discord));
2388        assert!(!mods.contains(&InitSetupModule::PublishCrates));
2389        assert!(!mods.contains(&InitSetupModule::CloudflareWorkers));
2390    }
2391
2392    #[test]
2393    fn default_modules_cargo_only() {
2394        let mods = default_modules_for_project(true, false, false, false, false);
2395        assert!(mods.contains(&InitSetupModule::CargoDist));
2396        assert!(mods.contains(&InitSetupModule::PublishCrates));
2397        assert!(!mods.contains(&InitSetupModule::PublishNpm));
2398        assert!(!mods.contains(&InitSetupModule::OciRegistry));
2399    }
2400
2401    #[test]
2402    fn default_modules_npm_only() {
2403        let mods = default_modules_for_project(false, true, false, false, false);
2404        assert!(mods.contains(&InitSetupModule::PublishNpm));
2405        assert!(!mods.contains(&InitSetupModule::PublishCrates));
2406        assert!(!mods.contains(&InitSetupModule::CargoDist));
2407    }
2408
2409    #[test]
2410    fn default_modules_dockerfile_enables_oci() {
2411        let mods = default_modules_for_project(false, false, false, true, false);
2412        assert!(mods.contains(&InitSetupModule::OciRegistry));
2413        assert!(!mods.contains(&InitSetupModule::Kubernetes));
2414    }
2415
2416    #[test]
2417    fn default_modules_k8s_without_docker() {
2418        let mods = default_modules_for_project(false, false, false, false, true);
2419        assert!(mods.contains(&InitSetupModule::Kubernetes));
2420        assert!(!mods.contains(&InitSetupModule::OciRegistry));
2421    }
2422
2423    #[test]
2424    fn default_modules_always_include_deploy_defaults() {
2425        for cargo in [false, true] {
2426            for npm in [false, true] {
2427                let mods = default_modules_for_project(cargo, npm, false, false, false);
2428                assert!(
2429                    mods.contains(&InitSetupModule::DeployDefaults),
2430                    "missing DeployDefaults cargo={cargo} npm={npm}"
2431                );
2432            }
2433        }
2434    }
2435
2436    #[test]
2437    fn detect_markers_finds_cargo_and_wrangler() {
2438        let dir = temp_dir("markers");
2439        fs::write(dir.join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
2440        fs::write(dir.join("wrangler.toml"), "name = \"x\"\n").unwrap();
2441        let m = detect_project_markers(&dir);
2442        assert!(m.has_cargo_toml);
2443        assert!(m.has_wrangler);
2444        let _ = fs::remove_dir_all(dir);
2445    }
2446
2447    #[test]
2448    fn detect_markers_finds_apps_package_json_and_k8s() {
2449        let dir = temp_dir("apps-k8s");
2450        fs::create_dir_all(dir.join("apps/web")).unwrap();
2451        fs::write(dir.join("apps/web/package.json"), "{}").unwrap();
2452        fs::create_dir_all(dir.join("k8s")).unwrap();
2453        fs::write(dir.join("Dockerfile"), "FROM scratch\n").unwrap();
2454        fs::write(dir.join("dist-workspace.toml"), "[workspace]\n").unwrap();
2455        let m = detect_project_markers(&dir);
2456        assert!(m.has_package_json);
2457        assert!(m.has_k8s_manifests);
2458        assert!(m.has_dockerfile);
2459        assert!(m.has_dist_workspace);
2460        let _ = fs::remove_dir_all(dir);
2461    }
2462
2463    #[test]
2464    fn detect_markers_finds_wrangler_jsonc_under_apps_web() {
2465        let dir = temp_dir("wrangler-jsonc");
2466        fs::create_dir_all(dir.join("apps/web")).unwrap();
2467        fs::write(dir.join("apps/web/wrangler.jsonc"), "{}\n").unwrap();
2468        let m = detect_project_markers(&dir);
2469        assert!(m.has_wrangler);
2470        assert!(find_wrangler_config(&dir).is_some());
2471        let _ = fs::remove_dir_all(dir);
2472    }
2473
2474    #[test]
2475    fn detect_markers_empty_tree() {
2476        let dir = temp_dir("empty");
2477        let m = detect_project_markers(&dir);
2478        assert!(!m.has_cargo_toml);
2479        assert!(!m.has_package_json);
2480        assert!(!m.has_wrangler);
2481        assert!(!m.has_dockerfile);
2482        assert!(!m.has_k8s_manifests);
2483        assert!(!m.has_dist_workspace);
2484        let _ = fs::remove_dir_all(dir);
2485    }
2486
2487    #[test]
2488    fn default_worker_root_templates_from_name() {
2489        assert_eq!(default_worker_root_for_name("docs"), "apps/docs");
2490        assert_eq!(default_worker_root_for_name("web"), "apps/web");
2491        assert_eq!(default_worker_root_for_name("  api  "), "apps/api");
2492        assert_eq!(default_worker_root_for_name("my worker"), "apps/my-worker");
2493        assert_eq!(default_worker_root_for_name(""), "apps/web");
2494        assert_eq!(default_worker_root_for_name("apps/nested"), "apps/apps-nested");
2495    }
2496
2497    #[test]
2498    fn discover_worker_roots_from_apps() {
2499        let dir = temp_dir("workers");
2500        fs::create_dir_all(dir.join("apps/web")).unwrap();
2501        fs::create_dir_all(dir.join("apps/api")).unwrap();
2502        fs::create_dir_all(dir.join("apps/docs")).unwrap();
2503        fs::write(dir.join("apps/web/wrangler.jsonc"), "{}\n").unwrap();
2504        fs::write(dir.join("apps/docs/wrangler.toml"), "name=\"d\"\n").unwrap();
2505        // no wrangler in api
2506        let roots = discover_worker_roots(&dir);
2507        assert_eq!(roots.len(), 2);
2508        assert!(roots.iter().any(|p| p.ends_with("web")));
2509        assert!(roots.iter().any(|p| p.ends_with("docs")));
2510        // Display paths must collapse to repo-relative (not absolute/hardcoded).
2511        for root in &roots {
2512            let rel = collapse_project_path(&dir, &root.to_string_lossy());
2513            assert!(
2514                !rel.contains(':') && !rel.starts_with('/'),
2515                "expected relative path, got {rel}"
2516            );
2517            assert!(rel.starts_with("apps/") || rel == "./");
2518        }
2519        let _ = fs::remove_dir_all(dir);
2520    }
2521
2522    #[test]
2523    fn discover_publish_packages_finds_cargo_and_npm() {
2524        let dir = temp_dir("publish-pkgs");
2525        fs::create_dir_all(dir.join("crates/a")).unwrap();
2526        fs::create_dir_all(dir.join("crates/b")).unwrap();
2527        fs::create_dir_all(dir.join("packages/ui")).unwrap();
2528        fs::write(
2529            dir.join("Cargo.toml"),
2530            "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n",
2531        )
2532        .unwrap();
2533        fs::write(
2534            dir.join("crates/a/Cargo.toml"),
2535            "[package]\nname = \"pkg_a\"\nversion = \"0.1.0\"\n",
2536        )
2537        .unwrap();
2538        fs::write(
2539            dir.join("crates/b/Cargo.toml"),
2540            "[package]\nname = \"pkg_b\"\nversion = \"0.1.0\"\n",
2541        )
2542        .unwrap();
2543        fs::write(
2544            dir.join("packages/ui/package.json"),
2545            r#"{"name":"@acme/ui","version":"1.0.0"}"#,
2546        )
2547        .unwrap();
2548        fs::write(
2549            dir.join("package.json"),
2550            r#"{"name":"root-app","private":true,"version":"0.0.0"}"#,
2551        )
2552        .unwrap();
2553
2554        let crates = discover_publish_packages(&dir, "crates");
2555        let names: Vec<_> = crates.iter().map(|p| p.package_name.as_str()).collect();
2556        assert!(names.contains(&"pkg_a"), "{names:?}");
2557        assert!(names.contains(&"pkg_b"), "{names:?}");
2558        assert!(
2559            !names.iter().any(|n| n.is_empty()),
2560            "virtual workspace must not appear"
2561        );
2562        assert!(crates.iter().any(|p| p.manifest_path == "crates/a/Cargo.toml"));
2563
2564        let npm = discover_publish_packages(&dir, "npm");
2565        let npm_names: Vec<_> = npm.iter().map(|p| p.package_name.as_str()).collect();
2566        assert!(npm_names.contains(&"@acme/ui"), "{npm_names:?}");
2567        assert!(npm_names.contains(&"root-app"), "{npm_names:?}");
2568        assert!(npm.iter().any(|p| p.manifest_path == "packages/ui/package.json"));
2569
2570        let _ = fs::remove_dir_all(dir);
2571    }
2572
2573    #[test]
2574    fn publish_target_package_entries_expand_monorepo_list() {
2575        let cfg = PublishTargetConfig {
2576            package_name: Some("primary".into()),
2577            manifest_path: Some("crates/primary/Cargo.toml".into()),
2578            packages: vec![
2579                PublishPackageEntry {
2580                    package_name: Some("a".into()),
2581                    manifest_path: Some("crates/a/Cargo.toml".into()),
2582                    working_directory: Some("crates/a".into()),
2583                },
2584                PublishPackageEntry {
2585                    package_name: Some("b".into()),
2586                    manifest_path: Some("crates/b/Cargo.toml".into()),
2587                    working_directory: Some("crates/b".into()),
2588                },
2589            ],
2590            ..Default::default()
2591        };
2592        let entries = cfg.package_entries();
2593        assert_eq!(entries.len(), 2);
2594        assert_eq!(entries[0].package_name.as_deref(), Some("a"));
2595        let scoped = cfg.for_package_entry(&entries[1]);
2596        assert_eq!(scoped.package_name.as_deref(), Some("b"));
2597        assert!(scoped.packages.is_empty());
2598    }
2599
2600    #[test]
2601    fn discover_worker_roots_includes_repo_root_wrangler() {
2602        let dir = temp_dir("root-worker");
2603        fs::write(dir.join("wrangler.toml"), "name=\"root\"\n").unwrap();
2604        let roots = discover_worker_roots(&dir);
2605        assert!(roots.iter().any(|p| p == &dir));
2606        let _ = fs::remove_dir_all(dir);
2607    }
2608
2609    #[test]
2610    fn all_modules_have_unique_labels() {
2611        let labels: Vec<_> = InitSetupModule::all().iter().map(|m| m.label()).collect();
2612        let mut set = std::collections::BTreeSet::new();
2613        for l in &labels {
2614            assert!(set.insert(*l), "duplicate label {l}");
2615        }
2616        assert_eq!(labels.len(), 12);
2617    }
2618
2619    #[test]
2620    fn all_modules_labels_are_non_empty() {
2621        for m in InitSetupModule::all() {
2622            assert!(!m.label().is_empty());
2623        }
2624    }
2625
2626    #[test]
2627    fn default_selected_matches_expected_set() {
2628        assert!(InitSetupModule::VersionTargets.default_selected());
2629        assert!(InitSetupModule::GitHubRelease.default_selected());
2630        assert!(InitSetupModule::LinearRelease.default_selected());
2631        assert!(InitSetupModule::CargoDist.default_selected());
2632        assert!(InitSetupModule::PublishCrates.default_selected());
2633        assert!(InitSetupModule::Discord.default_selected());
2634        assert!(InitSetupModule::DeployDefaults.default_selected());
2635        assert!(!InitSetupModule::PublishNpm.default_selected());
2636        assert!(!InitSetupModule::CloudflareWorkers.default_selected());
2637        assert!(!InitSetupModule::OciRegistry.default_selected());
2638        assert!(!InitSetupModule::Kubernetes.default_selected());
2639        assert!(!InitSetupModule::Monitoring.default_selected());
2640    }
2641
2642    #[test]
2643    fn build_github_release_config_enabled_branch() {
2644        let cfg = build_github_release_config(
2645            Some("xylex-group/xbp".into()),
2646            true,
2647            true,
2648            Some("releases/{SERVICE_NAME}-{VERSION}".into()),
2649        );
2650        assert_eq!(cfg.repository.as_deref(), Some("xylex-group/xbp"));
2651        assert!(cfg.auto_push_on_commit);
2652        let branch = cfg.release_branch.expect("branch");
2653        assert!(branch.enabled);
2654        assert_eq!(
2655            branch.naming_template.as_deref(),
2656            Some("releases/{SERVICE_NAME}-{VERSION}")
2657        );
2658    }
2659
2660    #[test]
2661    fn build_github_release_config_uses_default_template() {
2662        let cfg = build_github_release_config(None, false, true, None);
2663        let branch = cfg.release_branch.expect("branch");
2664        assert_eq!(
2665            branch.naming_template.as_deref(),
2666            Some(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
2667        );
2668        assert!(!cfg.auto_push_on_commit);
2669    }
2670
2671    #[test]
2672    fn build_github_release_config_disabled_branch() {
2673        let cfg = build_github_release_config(Some("o/r".into()), true, false, Some("x".into()));
2674        assert!(cfg.release_branch.is_none());
2675    }
2676
2677    #[test]
2678    fn build_linear_release_config_enabled_with_initiative() {
2679        let cfg = build_linear_release_config(
2680            true,
2681            vec!["init-uuid".into()],
2682            Some("xylex".into()),
2683        );
2684        let release = cfg.release.expect("release");
2685        assert_eq!(release.enabled, Some(true));
2686        assert_eq!(
2687            release.initiative_ids.clone().unwrap_or_default(),
2688            vec!["init-uuid".to_string()]
2689        );
2690        assert_eq!(release.organization_name.as_deref(), Some("xylex"));
2691        assert_eq!(cfg.organization_name.as_deref(), Some("xylex"));
2692    }
2693
2694    #[test]
2695    fn build_linear_release_config_disabled() {
2696        let cfg = build_linear_release_config(false, Vec::new(), None);
2697        assert_eq!(cfg.release.as_ref().and_then(|r| r.enabled), Some(false));
2698        assert!(cfg
2699            .release
2700            .as_ref()
2701            .and_then(|r| r.initiative_ids.as_ref())
2702            .is_none());
2703    }
2704
2705    #[test]
2706    fn parse_github_owner_repo_forms() {
2707        assert_eq!(
2708            parse_github_owner_repo("git@github.com:xylex-group/xbp.git").as_deref(),
2709            Some("xylex-group/xbp")
2710        );
2711        assert_eq!(
2712            parse_github_owner_repo("https://github.com/xylex-group/xbp").as_deref(),
2713            Some("xylex-group/xbp")
2714        );
2715        assert_eq!(
2716            parse_github_owner_repo("https://github.com/xylex-group/xbp.git").as_deref(),
2717            Some("xylex-group/xbp")
2718        );
2719        assert_eq!(
2720            parse_github_owner_repo("xylex-group/xbp").as_deref(),
2721            Some("xylex-group/xbp")
2722        );
2723        assert_eq!(parse_github_owner_repo("not-a-repo"), None);
2724        assert_eq!(parse_github_owner_repo(""), None);
2725    }
2726
2727    #[test]
2728    fn github_owner_from_repository_extracts_owner() {
2729        assert_eq!(
2730            github_owner_from_repository("suitsfinance/xbp").as_deref(),
2731            Some("suitsfinance")
2732        );
2733        assert_eq!(
2734            github_owner_from_repository("  xylex-group/athena  ").as_deref(),
2735            Some("xylex-group")
2736        );
2737        assert_eq!(github_owner_from_repository("solo"), None);
2738        assert_eq!(github_owner_from_repository(""), None);
2739        assert_eq!(github_owner_from_repository("/"), None);
2740    }
2741
2742    #[test]
2743    fn suggest_oci_registry_username_prefers_github_then_detected() {
2744        assert_eq!(
2745            suggest_oci_registry_username(Some("existing"), Some("org/repo"), Some("other/r"))
2746                .as_deref(),
2747            Some("existing")
2748        );
2749        assert_eq!(
2750            suggest_oci_registry_username(None, Some("suitsfinance/app"), Some("other/r"))
2751                .as_deref(),
2752            Some("suitsfinance")
2753        );
2754        assert_eq!(
2755            suggest_oci_registry_username(None, None, Some("detected-org/repo")).as_deref(),
2756            Some("detected-org")
2757        );
2758        assert_eq!(suggest_oci_registry_username(None, None, None), None);
2759        assert_eq!(
2760            suggest_oci_registry_username(Some("  "), Some("acme/x"), None).as_deref(),
2761            Some("acme")
2762        );
2763    }
2764
2765    #[test]
2766    fn mask_discord_webhook_url_hides_middle() {
2767        assert_eq!(mask_discord_webhook_url(""), "(empty)");
2768        assert_eq!(mask_discord_webhook_url("short"), "*****");
2769        assert_eq!(
2770            mask_discord_webhook_url("https://discord.com/api/webhooks/123/abc"),
2771            "http.../abc"
2772        );
2773    }
2774
2775    #[test]
2776    fn find_existing_discord_webhook_url_reads_dotenv() {
2777        let root = std::env::temp_dir().join(format!(
2778            "xbp-discord-env-{}",
2779            std::time::SystemTime::now()
2780                .duration_since(std::time::UNIX_EPOCH)
2781                .unwrap()
2782                .as_nanos()
2783        ));
2784        let _ = fs::remove_dir_all(&root);
2785        fs::create_dir_all(&root).unwrap();
2786        fs::write(
2787            root.join(".env.local"),
2788            "DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1/token\n",
2789        )
2790        .unwrap();
2791
2792        let found = find_existing_discord_webhook_url(&root).expect("webhook");
2793        assert_eq!(
2794            found.0.as_str(),
2795            "https://discord.com/api/webhooks/1/token"
2796        );
2797        match found.1 {
2798            DiscordWebhookEnvSource::Dotenv { path } => {
2799                assert!(path.ends_with(".env.local"));
2800            }
2801            other => panic!("expected dotenv source, got {other:?}"),
2802        }
2803
2804        let _ = fs::remove_dir_all(&root);
2805    }
2806
2807    #[test]
2808    fn split_csv_trims() {
2809        assert_eq!(split_csv(" shell , msi , "), vec!["shell", "msi"]);
2810    }
2811
2812    #[test]
2813    fn split_csv_empty_and_whitespace() {
2814        assert!(split_csv("").is_empty());
2815        assert!(split_csv("  ,  , ").is_empty());
2816        assert_eq!(split_csv("a"), vec!["a"]);
2817        assert_eq!(split_csv("a,b,c"), vec!["a", "b", "c"]);
2818    }
2819
2820    #[test]
2821    fn module_all_is_stable_order() {
2822        let a = InitSetupModule::all();
2823        let b = InitSetupModule::all();
2824        assert_eq!(a, b);
2825        assert_eq!(a[0], InitSetupModule::VersionTargets);
2826        assert_eq!(a[1], InitSetupModule::GitHubRelease);
2827        assert_eq!(a[2], InitSetupModule::LinearRelease);
2828        assert_eq!(*a.last().unwrap(), InitSetupModule::Monitoring);
2829    }
2830
2831    #[test]
2832    fn default_modules_include_github_and_linear_release() {
2833        let mods = default_modules_for_project(false, false, false, false, false);
2834        assert!(mods.contains(&InitSetupModule::GitHubRelease));
2835        assert!(mods.contains(&InitSetupModule::LinearRelease));
2836    }
2837}