1use 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#[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
97pub 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
131pub 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 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
168pub 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
194pub 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
236pub 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 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 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 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 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 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 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#[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
874pub 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 if value.get("private").and_then(|v| v.as_bool()) == Some(true) {
943 }
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 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
976async 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 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
1057fn 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
1216pub 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
1266pub 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 if !repo.contains('/') {
1278 return None;
1279 }
1280 Some(owner.to_string())
1281}
1282
1283pub 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 github_repo.map(|src| {
1366 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 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 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 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 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
1603fn 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#[derive(Debug, Clone, PartialEq, Eq)]
1718pub enum DiscordWebhookEnvSource {
1719 Dotenv { path: PathBuf },
1721 ProcessEnv,
1723}
1724
1725pub 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
1751pub 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 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
2056pub 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
2070pub 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 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 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 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 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 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 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 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}