use crate::config::{DiscordConfig, DiscordEventsConfig, LinearConfig, LinearReleaseConfig};
use crate::strategies::deployment_config::{
validate_github_release_branch_naming_template, DeployProjectConfig, GitHubProjectConfig,
GitHubReleaseBranchConfig, KubernetesProjectConfig, OciProjectConfig, OciRegistryConfig,
PublishDistConfig, PublishTargetConfig, WorkerConfig, WorkerDeployConfig, XbpConfig,
DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE, GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS,
};
use crate::utils::{
collapse_project_path, preferred_local_secret_env_path, project_config_display_path,
upsert_env_key,
};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Select};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum InitSetupModule {
VersionTargets,
GitHubRelease,
LinearRelease,
CargoDist,
PublishCrates,
PublishNpm,
CloudflareWorkers,
OciRegistry,
Kubernetes,
DeployDefaults,
Discord,
Monitoring,
}
impl InitSetupModule {
pub fn all() -> &'static [InitSetupModule] {
&[
Self::VersionTargets,
Self::GitHubRelease,
Self::LinearRelease,
Self::CargoDist,
Self::PublishCrates,
Self::PublishNpm,
Self::CloudflareWorkers,
Self::OciRegistry,
Self::Kubernetes,
Self::DeployDefaults,
Self::Discord,
Self::Monitoring,
]
}
pub fn label(self) -> &'static str {
match self {
Self::VersionTargets => "Version targets & release scope",
Self::GitHubRelease => "GitHub release branch + auto-push",
Self::LinearRelease => "Linear release posts (credentials + initiative)",
Self::CargoDist => "cargo-dist / dist packaging for GitHub releases",
Self::PublishCrates => "crates.io publish target",
Self::PublishNpm => "npm publish target",
Self::CloudflareWorkers => "Cloudflare Workers apps",
Self::OciRegistry => "OCI registry credentials (GHCR / etc.)",
Self::Kubernetes => "Kubernetes defaults (context / namespace)",
Self::DeployDefaults => "Deploy groups & default environment",
Self::Discord => "Discord webhook notifications",
Self::Monitoring => "Synthetic monitor URL",
}
}
pub fn default_selected(self) -> bool {
matches!(
self,
Self::VersionTargets
| Self::GitHubRelease
| Self::LinearRelease
| Self::CargoDist
| Self::PublishCrates
| Self::Discord
| Self::DeployDefaults
)
}
}
pub fn default_modules_for_project(
has_cargo_toml: bool,
has_package_json: bool,
has_wrangler: bool,
has_dockerfile: bool,
has_k8s_manifests: bool,
) -> Vec<InitSetupModule> {
let mut selected = vec![
InitSetupModule::VersionTargets,
InitSetupModule::GitHubRelease,
InitSetupModule::LinearRelease,
InitSetupModule::Discord,
];
if has_cargo_toml {
selected.push(InitSetupModule::CargoDist);
selected.push(InitSetupModule::PublishCrates);
}
if has_package_json {
selected.push(InitSetupModule::PublishNpm);
}
if has_wrangler {
selected.push(InitSetupModule::CloudflareWorkers);
}
if has_dockerfile {
selected.push(InitSetupModule::OciRegistry);
}
if has_k8s_manifests {
selected.push(InitSetupModule::Kubernetes);
}
selected.push(InitSetupModule::DeployDefaults);
selected
}
pub fn build_github_release_config(
repository: Option<String>,
auto_push_on_commit: bool,
release_branch_enabled: bool,
naming_template: Option<String>,
) -> GitHubProjectConfig {
let template = naming_template
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.and_then(|t| {
if validate_github_release_branch_naming_template(&t).is_ok() {
Some(t)
} else {
None
}
});
GitHubProjectConfig {
repository: repository
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty()),
auto_push_on_commit,
release_branch: if release_branch_enabled {
Some(GitHubReleaseBranchConfig {
enabled: true,
naming_template: template.or_else(|| {
Some(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE.to_string())
}),
})
} else {
None
},
runners: None,
}
}
pub fn build_linear_release_config(
enabled: bool,
initiative_ids: Vec<String>,
organization_name: Option<String>,
) -> LinearConfig {
let org = organization_name
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
LinearConfig {
release: Some(LinearReleaseConfig {
enabled: Some(enabled),
initiative_ids: if initiative_ids.is_empty() {
None
} else {
Some(initiative_ids)
},
organization_name: org.clone(),
health: None,
}),
default_team_id: None,
default_team_key: None,
organization_name: org,
}
}
pub fn detect_project_markers(project_root: &Path) -> ProjectMarkers {
ProjectMarkers {
has_cargo_toml: project_root.join("Cargo.toml").exists()
|| project_root.join("crates").exists(),
has_package_json: project_root.join("package.json").exists()
|| project_root.join("apps").exists()
|| project_root.join("packages").exists(),
has_wrangler: find_wrangler_config(project_root).is_some(),
has_dockerfile: project_root.join("Dockerfile").exists()
|| project_root.join("docker-compose.yml").exists()
|| project_root.join("compose.yml").exists(),
has_k8s_manifests: project_root.join("k8s").exists()
|| project_root.join("deploy/k8s").exists()
|| project_root.join("charts").exists(),
has_dist_workspace: project_root.join("dist-workspace.toml").exists(),
}
}
#[derive(Debug, Clone, Default)]
pub struct ProjectMarkers {
pub has_cargo_toml: bool,
pub has_package_json: bool,
pub has_wrangler: bool,
pub has_dockerfile: bool,
pub has_k8s_manifests: bool,
pub has_dist_workspace: bool,
}
fn find_wrangler_config(project_root: &Path) -> Option<PathBuf> {
let candidates = [
project_root.join("wrangler.toml"),
project_root.join("wrangler.jsonc"),
project_root.join("wrangler.json"),
project_root.join("apps/web/wrangler.jsonc"),
project_root.join("apps/web/wrangler.toml"),
project_root.join("apps/docs/wrangler.jsonc"),
project_root.join("apps/api/wrangler.jsonc"),
];
candidates.into_iter().find(|p| p.exists())
}
pub async fn run_init_setup_wizard(
project_root: &Path,
config: &mut XbpConfig,
) -> Result<Vec<String>, String> {
let theme = ColorfulTheme::default();
println!();
println!(
"{} {}",
"XBP init".bright_magenta().bold(),
"— full project setup wizard".bright_white()
);
println!(
"{}",
"Walk through release, publish, Workers, OCI/K8s, Discord, and more. Skip any step."
.bright_black()
);
println!();
let markers = detect_project_markers(project_root);
let defaults = default_modules_for_project(
markers.has_cargo_toml || markers.has_dist_workspace,
markers.has_package_json,
markers.has_wrangler,
markers.has_dockerfile,
markers.has_k8s_manifests,
);
let modules = InitSetupModule::all();
let default_flags: Vec<bool> = modules
.iter()
.map(|m| defaults.contains(m) || m.default_selected())
.collect();
let labels: Vec<&str> = modules.iter().map(|m| m.label()).collect();
let chosen = MultiSelect::with_theme(&theme)
.with_prompt("Select setup modules (space to toggle, enter to confirm)")
.items(&labels)
.defaults(&default_flags)
.interact()
.map_err(|e| e.to_string())?;
let selected: Vec<InitSetupModule> = chosen.into_iter().map(|i| modules[i]).collect();
if selected.is_empty() {
println!("{}", "No modules selected.".yellow());
return Ok(Vec::new());
}
let mut notes = Vec::new();
for module in selected {
println!();
println!(
"{} {}",
"▸".bright_cyan().bold(),
module.label().bright_white().bold()
);
match module {
InitSetupModule::VersionTargets => {
setup_version_targets(project_root, config, &theme, &mut notes)?;
}
InitSetupModule::GitHubRelease => {
setup_github_release(project_root, config, &theme, &mut notes).await?;
}
InitSetupModule::LinearRelease => {
setup_linear_release(config, &theme, &mut notes).await?;
}
InitSetupModule::CargoDist => {
setup_cargo_dist(config, &markers, &theme, &mut notes)?;
}
InitSetupModule::PublishCrates => {
setup_publish_target(config, "crates", project_root, &theme, &mut notes)?;
}
InitSetupModule::PublishNpm => {
setup_publish_target(config, "npm", project_root, &theme, &mut notes)?;
}
InitSetupModule::CloudflareWorkers => {
setup_cloudflare_workers(project_root, config, &theme, &mut notes)?;
}
InitSetupModule::OciRegistry => {
setup_oci(config, &theme, &mut notes)?;
}
InitSetupModule::Kubernetes => {
setup_kubernetes(config, &theme, &mut notes)?;
}
InitSetupModule::DeployDefaults => {
setup_deploy_defaults(config, &theme, &mut notes)?;
}
InitSetupModule::Discord => {
setup_discord(project_root, config, &theme, &mut notes)?;
}
InitSetupModule::Monitoring => {
setup_monitoring(config, &theme, &mut notes)?;
}
}
}
crate::commands::project_services::apply_service_defaults_after_init(config, project_root);
notes.push("services[] lifecycle defaults applied (commands, oci, deploy envs)".into());
let config_path = crate::utils::resolve_project_config_write_path(
project_root,
crate::utils::find_xbp_config_upwards(project_root).as_ref(),
);
crate::utils::write_xbp_project_config_at_path(&config_path, config)
.map_err(|e| format!("Write {}: {e}", config_path.display()))?;
notes.push(format!("Updated {}", config_path.display()));
println!();
println!(
"{} {}",
"Done.".green().bold(),
"Project setup modules applied.".bright_white()
);
println!("{}", "Next steps:".bright_cyan().bold());
println!(" {} validate host readiness", "xbp diag --full".dimmed());
println!(" {} bump & release", "xbp version release".dimmed());
println!(" {} plan deploys", "xbp deploy all --plan".dimmed());
println!(
" {} Discord test",
"xbp config discord test".dimmed()
);
if config.workers.as_ref().map(|w| !w.is_empty()).unwrap_or(false) {
println!(
" {} Workers",
"xbp workers list / xbp workers deploy".dimmed()
);
}
Ok(vec![config_path.display().to_string()])
}
fn setup_version_targets(
project_root: &Path,
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let discovered = crate::commands::project_services::discover_service_version_targets_for_project(
project_root,
project_root,
&config.ignore_paths,
);
if !discovered.is_empty() {
println!(
" Discovered {} version target(s):",
discovered.len()
);
for t in discovered.iter().take(12) {
println!(" • {}", t.dimmed());
}
if discovered.len() > 12 {
println!(" … and {} more", discovered.len() - 12);
}
}
let use_discovered = if discovered.is_empty() {
false
} else {
Confirm::with_theme(theme)
.with_prompt("Use discovered version targets?")
.default(true)
.interact()
.map_err(|e| e.to_string())?
};
if use_discovered {
for t in discovered {
if !config.version_targets.iter().any(|e| e == &t) {
config.version_targets.push(t);
}
}
config.version_targets.sort();
notes.push(format!(
"version_targets ← {} path(s)",
config.version_targets.len()
));
} else {
let extra: String = Input::with_theme(theme)
.with_prompt("Extra version targets (comma-separated paths, empty to skip)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
for part in extra.split(',') {
let t = part.trim();
if !t.is_empty() && !config.version_targets.iter().any(|e| e == t) {
config.version_targets.push(t.to_string());
}
}
}
let enable_release = Confirm::with_theme(theme)
.with_prompt("Enable repository-wide releases (`xbp version release`)?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable_release {
if let Some(name) = config.services.as_ref().and_then(|s| s.first()).map(|s| s.name.clone()) {
if !config.release_disabled.iter().any(|x| x == &name) {
config.release_disabled.push(name);
}
}
notes.push("release disabled for default service (adjust release_disabled as needed)".into());
} else {
notes.push("releases enabled — use `xbp version release`".into());
}
Ok(())
}
fn setup_cargo_dist(
config: &mut XbpConfig,
markers: &ProjectMarkers,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let enable = Confirm::with_theme(theme)
.with_prompt(format!(
"Enable cargo-dist packaging on release?{}",
if markers.has_dist_workspace {
" (dist-workspace.toml detected)"
} else {
""
}
))
.default(markers.has_dist_workspace || markers.has_cargo_toml)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
notes.push("cargo-dist left disabled".into());
return Ok(());
}
let installers_raw: String = Input::with_theme(theme)
.with_prompt("Installers (comma-separated: shell, msi, powershell, npm)")
.with_initial_text("shell,msi")
.interact_text()
.map_err(|e| e.to_string())?;
let installers = split_csv(&installers_raw);
let generate_ci = Confirm::with_theme(theme)
.with_prompt("Generate/update cargo-dist GitHub Actions CI?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
let mut publish = config.publish.clone().unwrap_or_default();
publish.dist = Some(PublishDistConfig {
enabled: Some(true),
packages: Vec::new(),
installers,
targets: Vec::new(),
artifacts_modes: vec!["host".into(), "global".into()],
allow_dirty: Some(true),
generate_ci: Some(generate_ci),
tool: None,
});
config.publish = Some(publish);
notes.push("publish.dist enabled for version release assets".into());
Ok(())
}
fn setup_publish_target(
config: &mut XbpConfig,
provider: &str,
project_root: &Path,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let enable = Confirm::with_theme(theme)
.with_prompt(format!("Configure {provider} publish target?"))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let default_pkg = config.project_name.clone();
let default_manifest = if provider == "crates" {
if project_root.join("Cargo.toml").exists() {
"Cargo.toml"
} else {
"crates/cli/Cargo.toml"
}
} else if project_root.join("package.json").exists() {
"package.json"
} else {
"apps/web/package.json"
};
let package_name: String = Input::with_theme(theme)
.with_prompt(format!("{provider} package name"))
.with_initial_text(default_pkg)
.interact_text()
.map_err(|e| e.to_string())?;
let manifest_path: String = Input::with_theme(theme)
.with_prompt("Manifest path (repo-relative)")
.with_initial_text(default_manifest)
.interact_text()
.map_err(|e| e.to_string())?;
let working_directory: String = Input::with_theme(theme)
.with_prompt("Working directory (repo-relative)")
.with_initial_text(".")
.interact_text()
.map_err(|e| e.to_string())?;
let token_default = if provider == "crates" {
"${CARGO_REGISTRY_TOKEN}"
} else {
"${NPM_TOKEN}"
};
let token: String = Input::with_theme(theme)
.with_prompt("Token env placeholder")
.with_initial_text(token_default)
.interact_text()
.map_err(|e| e.to_string())?;
let publish_command = if provider == "crates" {
"cargo publish --locked"
} else {
"npm publish --access public"
};
let target = PublishTargetConfig {
enabled: Some(true),
package_name: Some(package_name.trim().to_string()),
working_directory: Some(working_directory.trim().to_string()),
manifest_path: Some(manifest_path.trim().to_string()),
token: Some(token.trim().to_string()),
preflight_commands: Vec::new(),
publish_command: Some(publish_command.into()),
use_wsl: Some(false),
wsl_distribution: None,
generate_npmrc: if provider == "npm" { Some(true) } else { None },
access: if provider == "npm" {
Some("public".into())
} else {
None
},
..Default::default()
};
let mut publish = config.publish.clone().unwrap_or_default();
if provider == "crates" {
publish.crates = Some(target);
} else {
publish.npm = Some(target);
}
config.publish = Some(publish);
notes.push(format!("publish.{provider} configured"));
Ok(())
}
fn setup_cloudflare_workers(
project_root: &Path,
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let config_path = project_config_display_path(project_root);
let enable = Confirm::with_theme(theme)
.with_prompt(format!(
"Register Cloudflare Workers apps in {config_path}?"
))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let mut workers = config.workers.clone().unwrap_or_default();
let discovered = discover_worker_roots(project_root);
if !discovered.is_empty() {
println!(" Found Worker-like roots:");
for d in &discovered {
println!(" • {}", d.display().to_string().dimmed());
}
let use_all = Confirm::with_theme(theme)
.with_prompt("Register all discovered Worker roots?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if use_all {
for root in discovered {
let name = root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("worker")
.to_string();
let rel = collapse_project_path(project_root, &root.to_string_lossy());
if workers.iter().any(|w| w.root == rel || w.name == name) {
continue;
}
workers.push(WorkerConfig {
name: name.clone(),
root: rel,
script_name: Some(name),
service: None,
deploy: Some(WorkerDeployConfig {
..Default::default()
}),
container: None,
containers: None,
durable_objects: None,
});
}
}
}
let add_manual = Confirm::with_theme(theme)
.with_prompt("Add a Worker manually?")
.default(workers.is_empty())
.interact()
.map_err(|e| e.to_string())?;
if add_manual {
let name: String = Input::with_theme(theme)
.with_prompt("Worker name")
.with_initial_text("web")
.interact_text()
.map_err(|e| e.to_string())?;
let name = name.trim().to_string();
let suggested_root = default_worker_root_for_name(&name);
let root: String = Input::with_theme(theme)
.with_prompt("Worker root (repo-relative)")
.with_initial_text(&suggested_root)
.interact_text()
.map_err(|e| e.to_string())?;
workers.push(WorkerConfig {
name: name.clone(),
root: root.trim().to_string(),
script_name: Some(name),
service: None,
deploy: Some(WorkerDeployConfig::default()),
container: None,
containers: None,
durable_objects: None,
});
}
if workers.is_empty() {
notes.push("no workers registered — run `xbp workers deploy configure` later".into());
} else {
config.workers = Some(workers);
notes.push(format!("workers[] registered in {config_path}"));
println!(
" {}",
"Tip: configure credentials with `xbp config cloudflare setup`".dimmed()
);
}
Ok(())
}
pub fn default_worker_root_for_name(name: &str) -> String {
let segment = name
.trim()
.trim_matches(|c| c == '/' || c == '\\')
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | ' ' => '-',
c => c,
})
.collect::<String>()
.trim_matches('-')
.to_string();
let segment = if segment.is_empty() {
"web".to_string()
} else {
segment
};
format!("apps/{segment}")
}
fn discover_worker_roots(project_root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let apps = project_root.join("apps");
if apps.is_dir() {
if let Ok(rd) = fs::read_dir(&apps) {
for entry in rd.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let has_wrangler = ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]
.iter()
.any(|f| path.join(f).exists());
if has_wrangler {
out.push(path);
}
}
}
}
if project_root.join("wrangler.toml").exists() || project_root.join("wrangler.jsonc").exists() {
out.push(project_root.to_path_buf());
}
out.sort();
out.dedup();
out
}
fn setup_oci(
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let enable = Confirm::with_theme(theme)
.with_prompt("Configure an OCI registry (e.g. ghcr.io)?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let registry: String = Input::with_theme(theme)
.with_prompt("Registry host")
.with_initial_text("ghcr.io")
.interact_text()
.map_err(|e| e.to_string())?;
let username: String = Input::with_theme(theme)
.with_prompt("Registry username (or GitHub user/org)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let token_source: String = Input::with_theme(theme)
.with_prompt("Token env placeholder")
.with_initial_text("${GITHUB_TOKEN}")
.interact_text()
.map_err(|e| e.to_string())?;
let mut registries = HashMap::new();
registries.insert(
registry.trim().to_string(),
OciRegistryConfig {
provider: Some("github".into()),
username: Some(username.trim().to_string()).filter(|s| !s.is_empty()),
token_source: Some(token_source.trim().to_string()),
url: None,
},
);
config.oci = Some(OciProjectConfig {
registries,
default_registry: Some(registry.trim().to_string()),
});
notes.push(format!("oci.default_registry = {}", registry.trim()));
Ok(())
}
fn setup_kubernetes(
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let enable = Confirm::with_theme(theme)
.with_prompt("Configure Kubernetes deploy defaults?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let namespace: String = Input::with_theme(theme)
.with_prompt("Default namespace")
.with_initial_text("default")
.interact_text()
.map_err(|e| e.to_string())?;
let context: String = Input::with_theme(theme)
.with_prompt("Default kubectl context (empty = current)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let require_confirm = Confirm::with_theme(theme)
.with_prompt("Require context confirmation before apply?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
config.kubernetes = Some(KubernetesProjectConfig {
default_context: Some(context.trim().to_string()).filter(|s| !s.is_empty()),
default_namespace: Some(namespace.trim().to_string()),
require_context_confirmation_for_apply: Some(require_confirm),
kubeconfig: None,
});
notes.push("kubernetes defaults saved".into());
Ok(())
}
fn setup_deploy_defaults(
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let default_env: String = Input::with_theme(theme)
.with_prompt("Default deploy environment name")
.with_initial_text("production")
.interact_text()
.map_err(|e| e.to_string())?;
let service_names: Vec<String> = config
.services
.as_ref()
.map(|s| s.iter().map(|svc| svc.name.clone()).collect())
.unwrap_or_default();
let mut groups = HashMap::new();
if !service_names.is_empty() {
let create_all = Confirm::with_theme(theme)
.with_prompt(format!(
"Create deploy group `all` with {} service(s)?",
service_names.len()
))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if create_all {
groups.insert(
"all".to_string(),
crate::strategies::deployment_config::DeployGroupConfig {
services: service_names.clone(),
order: service_names.clone(),
description: Some("All project services".into()),
},
);
}
}
config.deploy = Some(DeployProjectConfig {
default_env: Some(default_env.trim().to_string()),
history_dir: Some(".xbp/deployments".into()),
lock_file: Some(".xbp/deploy.lock".into()),
groups,
});
notes.push(format!(
"deploy.default_env = {}",
default_env.trim()
));
Ok(())
}
fn setup_discord(
project_root: &Path,
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let enable = Confirm::with_theme(theme)
.with_prompt("Configure Discord release/deploy webhooks?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let config_path = project_config_display_path(project_root);
let env_path = preferred_local_secret_env_path(project_root);
let env_label = collapse_project_path(project_root, &env_path.to_string_lossy());
let modes = vec![
format!("Project webhook URL (stored in {config_path})"),
format!("Env placeholder ${{DISCORD_WEBHOOK_URL}} (writes {env_label})"),
"Skip (use `xbp config discord setup` later)".to_string(),
];
let idx = Select::with_theme(theme)
.with_prompt("Webhook configuration mode")
.items(&modes)
.default(1)
.interact()
.map_err(|e| e.to_string())?;
let webhook_url = match idx {
0 => {
let url: String = Input::with_theme(theme)
.with_prompt("Discord webhook URL")
.interact_text()
.map_err(|e| e.to_string())?;
let url = url.trim().to_string();
if url.is_empty() {
return Err("Discord webhook URL cannot be empty.".to_string());
}
Some(url)
}
1 => {
let url: String = Input::with_theme(theme)
.with_prompt("Discord webhook URL (saved to env file)")
.interact_text()
.map_err(|e| e.to_string())?;
let url = url.trim().to_string();
if url.is_empty() {
return Err("Discord webhook URL cannot be empty.".to_string());
}
upsert_env_key(&env_path, "DISCORD_WEBHOOK_URL", &url)?;
notes.push(format!(
"DISCORD_WEBHOOK_URL written to {env_label}"
));
Some("${DISCORD_WEBHOOK_URL}".into())
}
_ => None,
};
if let Some(url) = webhook_url {
let username: String = Input::with_theme(theme)
.with_prompt("Webhook display name")
.with_initial_text("XBP")
.interact_text()
.map_err(|e| e.to_string())?;
config.discord = Some(DiscordConfig {
enabled: Some(true),
webhook_url: Some(url),
username: Some(username),
avatar_url: None,
events: Some(DiscordEventsConfig {
version_release: Some(true),
github_tag: Some(false),
github_release: Some(false),
crates_io: Some(true),
npm: Some(true),
linear: Some(true),
cargo_dist: Some(true),
oci: Some(true),
workers_deploy: Some(true),
kubernetes_deploy: Some(true),
deploy: Some(true),
openapi: Some(true),
issues: Some(true),
commit: Some(true),
}),
});
notes.push("discord webhook configured (test with `xbp config discord test`)".into());
} else {
notes.push("discord skipped".into());
}
Ok(())
}
fn setup_monitoring(
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
let enable = Confirm::with_theme(theme)
.with_prompt("Configure a synthetic monitor URL?")
.default(false)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let url: String = Input::with_theme(theme)
.with_prompt("Monitor URL")
.with_initial_text(
config
.url
.clone()
.unwrap_or_else(|| "https://example.com/health".into()),
)
.interact_text()
.map_err(|e| e.to_string())?;
let method: String = Input::with_theme(theme)
.with_prompt("HTTP method")
.with_initial_text("GET")
.interact_text()
.map_err(|e| e.to_string())?;
let expected: u16 = Input::with_theme(theme)
.with_prompt("Expected status code")
.default(200)
.interact_text()
.map_err(|e| e.to_string())?;
let interval: u64 = Input::with_theme(theme)
.with_prompt("Interval seconds")
.default(60)
.interact_text()
.map_err(|e| e.to_string())?;
config.monitor_url = Some(url.trim().to_string());
config.monitor_method = Some(method.trim().to_ascii_uppercase());
config.monitor_expected_code = Some(expected);
config.monitor_interval = Some(interval);
notes.push("monitor_url configured".into());
Ok(())
}
async fn setup_github_release(
project_root: &Path,
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
println!(
" {}",
"Controls `xbp version release` GitHub branch creation and auto-push of generated commits."
.bright_black()
);
let detected_repo = detect_github_repository(project_root);
let repository: String = Input::with_theme(theme)
.with_prompt("GitHub repository (owner/name, empty to skip)")
.with_initial_text(detected_repo.unwrap_or_default())
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let auto_push = Confirm::with_theme(theme)
.with_prompt("Auto-push commits created by XBP (config/version changes)?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
let enable_branch = Confirm::with_theme(theme)
.with_prompt("Create a dedicated GitHub release branch on `xbp version release`?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
let naming_template = if enable_branch {
println!(
" {} {}",
"Template tokens:".bright_black(),
GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS.dimmed()
);
let tmpl: String = Input::with_theme(theme)
.with_prompt("Release branch naming template")
.with_initial_text(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
.validate_with(|input: &String| -> Result<(), String> {
validate_github_release_branch_naming_template(input)
})
.interact_text()
.map_err(|e| e.to_string())?;
Some(tmpl)
} else {
None
};
let setup_token = Confirm::with_theme(theme)
.with_prompt("Configure GitHub API token now? (`xbp config github set-key`)")
.default(false)
.interact()
.map_err(|e| e.to_string())?;
if setup_token {
match crate::commands::config_cmd::run_config_secret_set("github", None).await {
Ok(()) => notes.push("github API token stored in global config".into()),
Err(err) => {
println!(" {} {}", "GitHub token:".yellow(), err.dimmed());
notes.push("github token setup failed — run `xbp config github set-key` later".into());
}
}
}
config.github = Some(build_github_release_config(
Some(repository).filter(|s| !s.trim().is_empty()),
auto_push,
enable_branch,
naming_template,
));
if enable_branch {
notes.push(format!(
"github.release_branch enabled (template: {})",
config
.github
.as_ref()
.and_then(|g| g.release_branch.as_ref())
.and_then(|b| b.naming_template.as_deref())
.unwrap_or(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
));
} else {
notes.push("github.release_branch disabled".into());
}
if auto_push {
notes.push("github.auto_push_on_commit = true".into());
}
Ok(())
}
pub fn detect_github_repository(project_root: &Path) -> Option<String> {
let output = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(project_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_github_owner_repo(&url)
}
pub fn parse_github_owner_repo(url: &str) -> Option<String> {
let url = url.trim().trim_end_matches(".git");
if url.is_empty() {
return None;
}
if let Some(rest) = url.strip_prefix("git@github.com:") {
let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
return Some(format!("{}/{}", parts[0], parts[1]));
}
}
for prefix in [
"https://github.com/",
"http://github.com/",
"ssh://git@github.com/",
] {
if let Some(rest) = url.strip_prefix(prefix) {
let parts: Vec<&str> = rest.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() >= 2 {
return Some(format!("{}/{}", parts[0], parts[1]));
}
}
}
let parts: Vec<&str> = url.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() == 2 && !parts[0].contains(':') {
return Some(format!("{}/{}", parts[0], parts[1]));
}
None
}
async fn setup_linear_release(
config: &mut XbpConfig,
theme: &ColorfulTheme,
notes: &mut Vec<String>,
) -> Result<(), String> {
println!(
" {}",
"Posts release changelogs / updates to a Linear initiative during `xbp version release`."
.bright_black()
);
let enable = Confirm::with_theme(theme)
.with_prompt("Post release changelogs to Linear?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
config.linear = Some(build_linear_release_config(false, Vec::new(), None));
notes.push("linear.release.enabled = false".into());
return Ok(());
}
#[cfg(not(feature = "linear"))]
{
println!(
" {}",
"This CLI binary was built without `--features linear`. Enabling config only; rebuild with linear for initiative picker."
.yellow()
);
let org: String = Input::with_theme(theme)
.with_prompt("Linear organization slug (for initiative URLs, optional)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let id: String = Input::with_theme(theme)
.with_prompt("Linear initiative UUID (paste, or leave empty)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let mut ids = Vec::new();
if !id.trim().is_empty() {
ids.push(id.trim().to_string());
}
config.linear = Some(build_linear_release_config(
true,
ids,
Some(org).filter(|s| !s.trim().is_empty()),
));
notes.push(
"linear.release enabled (no interactive picker — binary lacks `linear` feature)".into(),
);
return Ok(());
}
#[cfg(feature = "linear")]
{
let ensure_key = Confirm::with_theme(theme)
.with_prompt("Ensure Linear API key is configured now?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
let api_key = if ensure_key {
match crate::commands::linear_cmd::ensure_linear_api_key().await {
Ok(key) => Some(key),
Err(err) => {
println!(" {} {}", "Linear key:".yellow(), err.dimmed());
None
}
}
} else {
None
};
let org: String = Input::with_theme(theme)
.with_prompt("Linear organization slug (for initiative URLs, optional)")
.allow_empty(true)
.with_initial_text(
config
.linear
.as_ref()
.and_then(|l| l.organization_name.clone())
.unwrap_or_default(),
)
.interact_text()
.map_err(|e| e.to_string())?;
let mut initiative_ids = Vec::new();
if let Some(api_key) = api_key.as_deref() {
match crate::commands::linear::fetch_available_initiatives(api_key).await {
Ok(initiatives) if !initiatives.is_empty() => {
let labels: Vec<String> = initiatives
.iter()
.map(|i| {
let status = i.status.clone().unwrap_or_default();
if status.is_empty() {
i.name.clone()
} else {
format!("{} [{}]", i.name, status)
}
})
.collect();
let selection = dialoguer::FuzzySelect::with_theme(theme)
.with_prompt("Select Linear initiative for release posts")
.default(0)
.items(&labels)
.interact_opt()
.map_err(|e| e.to_string())?;
if let Some(idx) = selection {
if let Some(init) = initiatives.get(idx) {
initiative_ids.push(init.id.clone());
println!(
" {} {} ({})",
"Selected:".green(),
init.name.bright_white(),
init.id.dimmed()
);
}
}
}
Ok(_) => {
println!(
" {}",
"No Linear initiatives found for this API key.".yellow()
);
}
Err(err) => {
println!(
" {} {}",
"Could not list initiatives:".yellow(),
err.dimmed()
);
}
}
}
if initiative_ids.is_empty() {
let manual: String = Input::with_theme(theme)
.with_prompt("Paste Linear initiative UUID (empty to enable without binding one)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
if !manual.trim().is_empty() {
initiative_ids.push(manual.trim().to_string());
}
}
let team_key: String = Input::with_theme(theme)
.with_prompt("Default Linear team key (e.g. XLX, empty to skip)")
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let mut linear = build_linear_release_config(
true,
initiative_ids.clone(),
Some(org.clone()).filter(|s| !s.trim().is_empty()),
);
if !team_key.trim().is_empty() {
linear.default_team_key = Some(team_key.trim().to_string());
}
config.linear = Some(linear);
if initiative_ids.is_empty() {
notes.push(
"linear.release enabled without initiative — run `xbp config linear select-initiative` later"
.into(),
);
} else {
notes.push(format!(
"linear.release → initiative {}",
initiative_ids.join(", ")
));
}
Ok(())
}
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!("xbp-init-wizard-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn default_modules_include_cargo_and_workers_when_markers_present() {
let mods = default_modules_for_project(true, true, true, true, true);
assert!(mods.contains(&InitSetupModule::CargoDist));
assert!(mods.contains(&InitSetupModule::PublishCrates));
assert!(mods.contains(&InitSetupModule::PublishNpm));
assert!(mods.contains(&InitSetupModule::CloudflareWorkers));
assert!(mods.contains(&InitSetupModule::OciRegistry));
assert!(mods.contains(&InitSetupModule::Kubernetes));
assert!(mods.contains(&InitSetupModule::Discord));
}
#[test]
fn default_modules_minimal_without_markers() {
let mods = default_modules_for_project(false, false, false, false, false);
assert!(mods.contains(&InitSetupModule::VersionTargets));
assert!(mods.contains(&InitSetupModule::Discord));
assert!(!mods.contains(&InitSetupModule::PublishCrates));
assert!(!mods.contains(&InitSetupModule::CloudflareWorkers));
}
#[test]
fn default_modules_cargo_only() {
let mods = default_modules_for_project(true, false, false, false, false);
assert!(mods.contains(&InitSetupModule::CargoDist));
assert!(mods.contains(&InitSetupModule::PublishCrates));
assert!(!mods.contains(&InitSetupModule::PublishNpm));
assert!(!mods.contains(&InitSetupModule::OciRegistry));
}
#[test]
fn default_modules_npm_only() {
let mods = default_modules_for_project(false, true, false, false, false);
assert!(mods.contains(&InitSetupModule::PublishNpm));
assert!(!mods.contains(&InitSetupModule::PublishCrates));
assert!(!mods.contains(&InitSetupModule::CargoDist));
}
#[test]
fn default_modules_dockerfile_enables_oci() {
let mods = default_modules_for_project(false, false, false, true, false);
assert!(mods.contains(&InitSetupModule::OciRegistry));
assert!(!mods.contains(&InitSetupModule::Kubernetes));
}
#[test]
fn default_modules_k8s_without_docker() {
let mods = default_modules_for_project(false, false, false, false, true);
assert!(mods.contains(&InitSetupModule::Kubernetes));
assert!(!mods.contains(&InitSetupModule::OciRegistry));
}
#[test]
fn default_modules_always_include_deploy_defaults() {
for cargo in [false, true] {
for npm in [false, true] {
let mods = default_modules_for_project(cargo, npm, false, false, false);
assert!(
mods.contains(&InitSetupModule::DeployDefaults),
"missing DeployDefaults cargo={cargo} npm={npm}"
);
}
}
}
#[test]
fn detect_markers_finds_cargo_and_wrangler() {
let dir = temp_dir("markers");
fs::write(dir.join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
fs::write(dir.join("wrangler.toml"), "name = \"x\"\n").unwrap();
let m = detect_project_markers(&dir);
assert!(m.has_cargo_toml);
assert!(m.has_wrangler);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn detect_markers_finds_apps_package_json_and_k8s() {
let dir = temp_dir("apps-k8s");
fs::create_dir_all(dir.join("apps/web")).unwrap();
fs::write(dir.join("apps/web/package.json"), "{}").unwrap();
fs::create_dir_all(dir.join("k8s")).unwrap();
fs::write(dir.join("Dockerfile"), "FROM scratch\n").unwrap();
fs::write(dir.join("dist-workspace.toml"), "[workspace]\n").unwrap();
let m = detect_project_markers(&dir);
assert!(m.has_package_json);
assert!(m.has_k8s_manifests);
assert!(m.has_dockerfile);
assert!(m.has_dist_workspace);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn detect_markers_finds_wrangler_jsonc_under_apps_web() {
let dir = temp_dir("wrangler-jsonc");
fs::create_dir_all(dir.join("apps/web")).unwrap();
fs::write(dir.join("apps/web/wrangler.jsonc"), "{}\n").unwrap();
let m = detect_project_markers(&dir);
assert!(m.has_wrangler);
assert!(find_wrangler_config(&dir).is_some());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn detect_markers_empty_tree() {
let dir = temp_dir("empty");
let m = detect_project_markers(&dir);
assert!(!m.has_cargo_toml);
assert!(!m.has_package_json);
assert!(!m.has_wrangler);
assert!(!m.has_dockerfile);
assert!(!m.has_k8s_manifests);
assert!(!m.has_dist_workspace);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn default_worker_root_templates_from_name() {
assert_eq!(default_worker_root_for_name("docs"), "apps/docs");
assert_eq!(default_worker_root_for_name("web"), "apps/web");
assert_eq!(default_worker_root_for_name(" api "), "apps/api");
assert_eq!(default_worker_root_for_name("my worker"), "apps/my-worker");
assert_eq!(default_worker_root_for_name(""), "apps/web");
assert_eq!(default_worker_root_for_name("apps/nested"), "apps/apps-nested");
}
#[test]
fn discover_worker_roots_from_apps() {
let dir = temp_dir("workers");
fs::create_dir_all(dir.join("apps/web")).unwrap();
fs::create_dir_all(dir.join("apps/api")).unwrap();
fs::create_dir_all(dir.join("apps/docs")).unwrap();
fs::write(dir.join("apps/web/wrangler.jsonc"), "{}\n").unwrap();
fs::write(dir.join("apps/docs/wrangler.toml"), "name=\"d\"\n").unwrap();
let roots = discover_worker_roots(&dir);
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|p| p.ends_with("web")));
assert!(roots.iter().any(|p| p.ends_with("docs")));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn discover_worker_roots_includes_repo_root_wrangler() {
let dir = temp_dir("root-worker");
fs::write(dir.join("wrangler.toml"), "name=\"root\"\n").unwrap();
let roots = discover_worker_roots(&dir);
assert!(roots.iter().any(|p| p == &dir));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn all_modules_have_unique_labels() {
let labels: Vec<_> = InitSetupModule::all().iter().map(|m| m.label()).collect();
let mut set = std::collections::BTreeSet::new();
for l in &labels {
assert!(set.insert(*l), "duplicate label {l}");
}
assert_eq!(labels.len(), 12);
}
#[test]
fn all_modules_labels_are_non_empty() {
for m in InitSetupModule::all() {
assert!(!m.label().is_empty());
}
}
#[test]
fn default_selected_matches_expected_set() {
assert!(InitSetupModule::VersionTargets.default_selected());
assert!(InitSetupModule::GitHubRelease.default_selected());
assert!(InitSetupModule::LinearRelease.default_selected());
assert!(InitSetupModule::CargoDist.default_selected());
assert!(InitSetupModule::PublishCrates.default_selected());
assert!(InitSetupModule::Discord.default_selected());
assert!(InitSetupModule::DeployDefaults.default_selected());
assert!(!InitSetupModule::PublishNpm.default_selected());
assert!(!InitSetupModule::CloudflareWorkers.default_selected());
assert!(!InitSetupModule::OciRegistry.default_selected());
assert!(!InitSetupModule::Kubernetes.default_selected());
assert!(!InitSetupModule::Monitoring.default_selected());
}
#[test]
fn build_github_release_config_enabled_branch() {
let cfg = build_github_release_config(
Some("xylex-group/xbp".into()),
true,
true,
Some("releases/{SERVICE_NAME}-{VERSION}".into()),
);
assert_eq!(cfg.repository.as_deref(), Some("xylex-group/xbp"));
assert!(cfg.auto_push_on_commit);
let branch = cfg.release_branch.expect("branch");
assert!(branch.enabled);
assert_eq!(
branch.naming_template.as_deref(),
Some("releases/{SERVICE_NAME}-{VERSION}")
);
}
#[test]
fn build_github_release_config_uses_default_template() {
let cfg = build_github_release_config(None, false, true, None);
let branch = cfg.release_branch.expect("branch");
assert_eq!(
branch.naming_template.as_deref(),
Some(DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE)
);
assert!(!cfg.auto_push_on_commit);
}
#[test]
fn build_github_release_config_disabled_branch() {
let cfg = build_github_release_config(Some("o/r".into()), true, false, Some("x".into()));
assert!(cfg.release_branch.is_none());
}
#[test]
fn build_linear_release_config_enabled_with_initiative() {
let cfg = build_linear_release_config(
true,
vec!["init-uuid".into()],
Some("xylex".into()),
);
let release = cfg.release.expect("release");
assert_eq!(release.enabled, Some(true));
assert_eq!(
release.initiative_ids.clone().unwrap_or_default(),
vec!["init-uuid".to_string()]
);
assert_eq!(release.organization_name.as_deref(), Some("xylex"));
assert_eq!(cfg.organization_name.as_deref(), Some("xylex"));
}
#[test]
fn build_linear_release_config_disabled() {
let cfg = build_linear_release_config(false, Vec::new(), None);
assert_eq!(cfg.release.as_ref().and_then(|r| r.enabled), Some(false));
assert!(cfg
.release
.as_ref()
.and_then(|r| r.initiative_ids.as_ref())
.is_none());
}
#[test]
fn parse_github_owner_repo_forms() {
assert_eq!(
parse_github_owner_repo("git@github.com:xylex-group/xbp.git").as_deref(),
Some("xylex-group/xbp")
);
assert_eq!(
parse_github_owner_repo("https://github.com/xylex-group/xbp").as_deref(),
Some("xylex-group/xbp")
);
assert_eq!(
parse_github_owner_repo("https://github.com/xylex-group/xbp.git").as_deref(),
Some("xylex-group/xbp")
);
assert_eq!(
parse_github_owner_repo("xylex-group/xbp").as_deref(),
Some("xylex-group/xbp")
);
assert_eq!(parse_github_owner_repo("not-a-repo"), None);
assert_eq!(parse_github_owner_repo(""), None);
}
#[test]
fn split_csv_trims() {
assert_eq!(split_csv(" shell , msi , "), vec!["shell", "msi"]);
}
#[test]
fn split_csv_empty_and_whitespace() {
assert!(split_csv("").is_empty());
assert!(split_csv(" , , ").is_empty());
assert_eq!(split_csv("a"), vec!["a"]);
assert_eq!(split_csv("a,b,c"), vec!["a", "b", "c"]);
}
#[test]
fn module_all_is_stable_order() {
let a = InitSetupModule::all();
let b = InitSetupModule::all();
assert_eq!(a, b);
assert_eq!(a[0], InitSetupModule::VersionTargets);
assert_eq!(a[1], InitSetupModule::GitHubRelease);
assert_eq!(a[2], InitSetupModule::LinearRelease);
assert_eq!(*a.last().unwrap(), InitSetupModule::Monitoring);
}
#[test]
fn default_modules_include_github_and_linear_release() {
let mods = default_modules_for_project(false, false, false, false, false);
assert!(mods.contains(&InitSetupModule::GitHubRelease));
assert!(mods.contains(&InitSetupModule::LinearRelease));
}
}