use crate::cli::interactive::{print_picker_header, xbp_theme};
use crate::config::{
mask_registry_token, resolve_crates_token_from, resolve_linear_api_key, resolve_npm_token_from,
DiscordConfig, DiscordEventsConfig, LinearConfig, LinearReleaseConfig,
};
use crate::strategies::deployment_config::{
validate_github_release_branch_naming_template, DeployProjectConfig, GitHubProjectConfig,
GitHubReleaseBranchConfig, KubernetesProjectConfig, OciProjectConfig, OciRegistryConfig,
PublishDistConfig, PublishPackageEntry, PublishTargetConfig, WorkerConfig, WorkerDeployConfig,
XbpConfig, DEFAULT_GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE,
GITHUB_RELEASE_BRANCH_NAMING_TEMPLATE_TOKENS,
};
use crate::utils::{
collapse_project_path, first_dotenv_value_upwards, preferred_local_secret_env_path,
project_config_display_path, upsert_env_key, DEFAULT_DISCOVERY_SKIP_DIRS,
};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, MultiSelect, Select};
use std::collections::{BTreeSet, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Stdio;
#[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 = xbp_theme();
print_picker_header(
"xbp init · full setup",
"Walk through release, publish, Workers, OCI/K8s, Discord, and more. Skip any step.",
);
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, project_root, &markers, &theme, &mut notes)?;
}
InitSetupModule::PublishCrates => {
setup_publish_target(config, "crates", project_root, &theme, &mut notes).await?;
}
InitSetupModule::PublishNpm => {
setup_publish_target(config, "npm", project_root, &theme, &mut notes).await?;
}
InitSetupModule::CloudflareWorkers => {
setup_cloudflare_workers(project_root, config, &theme, &mut notes)?;
}
InitSetupModule::OciRegistry => {
setup_oci(project_root, 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,
project_root: &Path,
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 installer_options = [
("shell", "Unix shell installer"),
("msi", "Windows MSI"),
("powershell", "Windows PowerShell installer"),
("npm", "npm global installer package"),
("homebrew", "Homebrew formula"),
];
let labels: Vec<String> = installer_options
.iter()
.map(|(id, desc)| format!("{id} — {desc}"))
.collect();
let defaults: Vec<bool> = installer_options
.iter()
.map(|(id, _)| *id == "shell" || *id == "msi")
.collect();
print_picker_header(
"cargo-dist · installers",
"Space toggles · Enter confirms · multi-select",
);
let chosen = MultiSelect::with_theme(theme)
.with_prompt("Installers to enable")
.items(&labels)
.defaults(&defaults)
.interact()
.map_err(|e| e.to_string())?;
let installers: Vec<String> = if chosen.is_empty() {
vec!["shell".into(), "msi".into()]
} else {
chosen
.into_iter()
.map(|i| installer_options[i].0.to_string())
.collect()
};
let mut packages = config
.publish
.as_ref()
.and_then(|p| p.crates.as_ref())
.map(|c| {
c.package_entries()
.into_iter()
.filter_map(|e| e.package_name)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if packages.is_empty() {
packages = discover_publish_packages(project_root, "crates")
.into_iter()
.map(|p| p.package_name)
.collect();
}
if packages.len() > 1 {
let defaults: Vec<bool> = packages.iter().map(|_| true).collect();
print_picker_header(
"cargo-dist · packages",
"Which crates should cargo-dist package on release?",
);
let chosen = MultiSelect::with_theme(theme)
.with_prompt("cargo-dist packages")
.items(&packages)
.defaults(&defaults)
.interact()
.map_err(|e| e.to_string())?;
if !chosen.is_empty() {
packages = chosen.into_iter().map(|i| packages[i].clone()).collect();
}
}
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,
installers: installers.clone(),
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(format!(
"publish.dist enabled (installers: {})",
installers.join(", ")
));
Ok(())
}
async 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(s)?"))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if !enable {
return Ok(());
}
let discovered = discover_publish_packages(project_root, provider);
let selected_entries = if discovered.is_empty() {
println!(
" {}",
format!("No {provider} packages auto-detected — enter manually.")
.yellow()
);
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 {
"packages/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 = Path::new(manifest_path.trim())
.parent()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.filter(|p| !p.is_empty())
.unwrap_or_else(|| ".".to_string());
vec![PublishPackageEntry {
package_name: Some(package_name.trim().to_string()),
working_directory: Some(working_directory),
manifest_path: Some(manifest_path.trim().to_string()),
}]
} else {
print_picker_header(
&format!("xbp init · {provider} packages"),
"Discovered via project detector (Cargo.toml / package.json). Space toggles · multi-select monorepo packages.",
);
let labels: Vec<String> = discovered
.iter()
.map(|p| {
format!(
"{} · {}",
pad_col(&p.package_name, 32),
p.manifest_path
)
})
.collect();
let defaults: Vec<bool> = discovered.iter().map(|_| true).collect();
let chosen = MultiSelect::with_theme(theme)
.with_prompt(format!("Select {provider} package(s) to publish"))
.items(&labels)
.defaults(&defaults)
.interact()
.map_err(|e| e.to_string())?;
let indices: Vec<usize> = if chosen.is_empty() {
vec![0]
} else {
chosen
};
indices
.into_iter()
.filter_map(|i| discovered.get(i).cloned())
.map(|p| PublishPackageEntry {
package_name: Some(p.package_name),
working_directory: Some(p.working_directory),
manifest_path: Some(p.manifest_path),
})
.collect()
};
if selected_entries.is_empty() {
notes.push(format!("publish.{provider} skipped (no packages selected)"));
return Ok(());
}
for entry in &selected_entries {
if let Some(manifest) = entry.manifest_path.as_deref() {
let rel = collapse_project_path(project_root, manifest);
if !config.version_targets.iter().any(|t| t == &rel || t == manifest) {
config.version_targets.push(rel);
}
}
}
let token_placeholder = if provider == "crates" {
"${CARGO_REGISTRY_TOKEN}"
} else {
"${NPM_TOKEN}"
};
let token_label = if provider == "crates" {
"CARGO_REGISTRY_TOKEN"
} else {
"NPM_TOKEN"
};
let resolved = if provider == "crates" {
resolve_crates_token_from(Some(project_root))
} else {
resolve_npm_token_from(Some(project_root))
};
let mut token_ok = false;
match &resolved {
Some(token) => {
println!(
" {} {} detected ({})",
"✓".bright_green().bold(),
token_label.bright_white(),
mask_registry_token(token).dimmed()
);
match verify_registry_token(provider, project_root, token).await {
Ok(detail) => {
println!(
" {} {} token valid{}",
"✓".bright_green().bold(),
token_label,
detail
.as_deref()
.map(|d| format!(" ({d})"))
.unwrap_or_default()
.bright_black()
);
notes.push(format!("{token_label} auto-detected and verified"));
token_ok = true;
}
Err(err) => {
println!(
" {} {} present but verification failed: {}",
"!".bright_yellow().bold(),
token_label,
err.dimmed()
);
notes.push(format!(
"{token_label} found but verification failed — fix before publish"
));
}
}
}
None => {
println!(
" {} {} not found in process env, project .env/.env.local, or global config",
"·".bright_black(),
token_label.yellow()
);
notes.push(format!(
"{token_label} not detected — publish will need it at release time"
));
}
}
if !token_ok {
let env_path = preferred_local_secret_env_path(project_root);
let env_display = collapse_project_path(project_root, &env_path.to_string_lossy());
let prompt_secret = Confirm::with_theme(theme)
.with_prompt(format!(
"Paste a valid {token_label} now and store it in `{env_display}`?"
))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if prompt_secret {
let secret: String = Input::with_theme(theme)
.with_prompt(format!("{token_label} secret (written to {env_display} only)"))
.allow_empty(false)
.interact_text()
.map_err(|e| e.to_string())?;
let secret = secret.trim().to_string();
if !secret.is_empty() {
ensure_dotenv_gitignored(project_root)?;
upsert_env_key(&env_path, token_label, &secret)?;
println!(
" {} wrote {} to {} (gitignored)",
"✓".bright_green().bold(),
token_label.bright_white(),
env_display.bright_white()
);
match verify_registry_token(provider, project_root, &secret).await {
Ok(detail) => {
println!(
" {} {} token valid{}",
"✓".bright_green().bold(),
token_label,
detail
.as_deref()
.map(|d| format!(" ({d})"))
.unwrap_or_default()
.bright_black()
);
notes.push(format!("{token_label} saved to {env_display} and verified"));
token_ok = true;
}
Err(err) => {
println!(
" {} saved to {} but verification still failed: {}",
"!".bright_yellow().bold(),
env_display,
err.dimmed()
);
notes.push(format!(
"{token_label} saved to {env_display} but verification failed — check token scope"
));
}
}
}
} else {
println!(
" {}",
format!(
"Add {token_label} to `{env_display}` later, or run `xbp config {provider} set-key`."
)
.bright_black()
);
}
let _ = token_ok;
}
ensure_dotenv_gitignored(project_root)?;
let token: String = Input::with_theme(theme)
.with_prompt("Token env placeholder (stored in config, not the secret)")
.with_initial_text(token_placeholder)
.interact_text()
.map_err(|e| e.to_string())?;
let publish_command = if provider == "crates" {
"cargo publish --locked"
} else {
"npm publish --access public"
};
let primary = &selected_entries[0];
let packages = if selected_entries.len() > 1 {
selected_entries.clone()
} else {
Vec::new()
};
let target = PublishTargetConfig {
enabled: Some(true),
package_name: primary.package_name.clone(),
working_directory: primary.working_directory.clone(),
manifest_path: primary.manifest_path.clone(),
packages,
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 names: Vec<String> = selected_entries
.iter()
.filter_map(|e| e.package_name.clone())
.collect();
let mut publish = config.publish.clone().unwrap_or_default();
if provider == "crates" {
publish.crates = Some(target);
if let Some(dist) = publish.dist.as_mut() {
if dist.packages.is_empty() {
dist.packages = names.clone();
}
}
} else {
publish.npm = Some(target);
}
config.publish = Some(publish);
notes.push(format!(
"publish.{provider} → {} package(s): {}",
names.len(),
names.join(", ")
));
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredPublishPackage {
pub package_name: String,
pub manifest_path: String,
pub working_directory: String,
}
pub fn discover_publish_packages(project_root: &Path, provider: &str) -> Vec<DiscoveredPublishPackage> {
let manifest_name = match provider {
"npm" => "package.json",
"crates" => "Cargo.toml",
_ => return Vec::new(),
};
let mut out = Vec::new();
let mut seen_manifests = BTreeSet::new();
let mut stack = vec![project_root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(rd) = fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if DEFAULT_DISCOVERY_SKIP_DIRS
.iter()
.any(|skip| name.eq_ignore_ascii_case(skip))
|| name.starts_with('.')
{
continue;
}
stack.push(path);
continue;
}
if name != manifest_name {
continue;
}
let abs = path;
let rel = collapse_project_path(project_root, &abs.to_string_lossy());
if !seen_manifests.insert(rel.clone()) {
continue;
}
let Some(package_name) = read_publish_package_name(provider, &abs) else {
continue;
};
let working_directory = abs
.parent()
.map(|p| collapse_project_path(project_root, &p.to_string_lossy()))
.unwrap_or_else(|| "./".to_string());
let working_directory = if working_directory == "./" {
".".to_string()
} else {
working_directory.trim_start_matches("./").to_string()
};
out.push(DiscoveredPublishPackage {
package_name,
manifest_path: rel.trim_start_matches("./").to_string(),
working_directory,
});
}
}
out.sort_by(|a, b| a.package_name.cmp(&b.package_name));
out
}
fn read_publish_package_name(provider: &str, manifest: &Path) -> Option<String> {
let content = fs::read_to_string(manifest).ok()?;
match provider {
"npm" => {
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
if value.get("private").and_then(|v| v.as_bool()) == Some(true) {
}
value
.get("name")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
"crates" => {
let value: toml::Value = toml::from_str(&content).ok()?;
value
.get("package")
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
_ => None,
}
}
fn pad_col(s: &str, width: usize) -> String {
let len = s.chars().count();
if len >= width {
s.chars().take(width).collect()
} else {
format!("{s}{}", " ".repeat(width - len))
}
}
async fn verify_registry_token(
provider: &str,
project_root: &Path,
token: &str,
) -> Result<Option<String>, String> {
let token = token.trim();
if token.is_empty() {
return Err("empty token".into());
}
match provider {
"npm" => verify_npm_token(project_root, token).await,
"crates" => verify_crates_token(token).await,
_ => Ok(None),
}
}
async fn verify_npm_token(project_root: &Path, token: &str) -> Result<Option<String>, String> {
let mut cmd = tokio::process::Command::new("npm");
cmd.args(["whoami", "--registry", "https://registry.npmjs.org/"])
.current_dir(project_root)
.env("NPM_TOKEN", token)
.env("NODE_AUTH_TOKEN", token)
.env("npm_config_//registry.npmjs.org/:_authToken", token)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd
.output()
.await
.map_err(|e| format!("failed to run `npm whoami`: {e}"))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
let out = String::from_utf8_lossy(&output.stdout);
let detail = [err.trim(), out.trim()]
.into_iter()
.find(|s| !s.is_empty())
.unwrap_or("npm whoami failed");
return Err(detail.to_string());
}
let user = String::from_utf8_lossy(&output.stdout).trim().to_string();
if user.is_empty() {
return Err("npm whoami returned empty username".into());
}
Ok(Some(user))
}
async fn verify_crates_token(token: &str) -> Result<Option<String>, String> {
let token = token
.trim()
.strip_prefix("Bearer ")
.or_else(|| token.trim().strip_prefix("bearer "))
.unwrap_or(token.trim());
let client = reqwest::Client::builder()
.user_agent("xbp-init")
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://crates.io/api/v1/me")
.header("Authorization", token)
.send()
.await
.map_err(|e| format!("crates.io /me request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("crates.io /me returned HTTP {status}"));
}
let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let login = body
.pointer("/user/login")
.and_then(|v| v.as_str())
.or_else(|| body.pointer("/user/name").and_then(|v| v.as_str()))
.map(str::to_string);
Ok(login)
}
fn ensure_dotenv_gitignored(project_root: &Path) -> Result<(), String> {
let gitignore = project_root.join(".gitignore");
let required = [".env", ".env.local", ".env.*.local"];
let existing = if gitignore.exists() {
fs::read_to_string(&gitignore)
.map_err(|e| format!("Failed to read {}: {e}", gitignore.display()))?
} else {
String::new()
};
let lines: BTreeSet<String> = existing
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
let mut missing: Vec<&str> = required
.iter()
.copied()
.filter(|pat| {
!lines.iter().any(|line| {
line == pat
|| line == &format!("/{pat}")
|| line == &format!("**/{pat}")
|| line.strip_prefix('/') == Some(*pat)
})
})
.collect();
if missing.is_empty() {
return Ok(());
}
missing.sort();
let listed = missing.join(", ");
let mut out = existing;
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.contains("# xbp secrets") {
out.push_str("\n# xbp secrets (local only — never commit)\n");
}
for pat in &missing {
out.push_str(pat);
out.push('\n');
}
fs::write(&gitignore, out)
.map_err(|e| format!("Failed to update {}: {e}", gitignore.display()))?;
println!(
" {} updated .gitignore for local dotenv secrets ({})",
"✓".bright_green().bold(),
listed.bright_white()
);
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:".bright_blue().bold());
for d in &discovered {
let rel = collapse_project_path(project_root, &d.to_string_lossy());
let display = if rel == "./" {
".".to_string()
} else {
rel.trim_start_matches("./").to_string()
};
println!(" {} {}", "•".bright_cyan(), display.bright_white());
}
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
}
pub fn github_owner_from_repository(repository: &str) -> Option<String> {
let repo = repository.trim().trim_matches('/');
if repo.is_empty() {
return None;
}
let owner = repo.split('/').next()?.trim();
if owner.is_empty() || owner.contains(':') {
return None;
}
if !repo.contains('/') {
return None;
}
Some(owner.to_string())
}
pub fn suggest_oci_registry_username(
existing_oci_username: Option<&str>,
github_repository: Option<&str>,
detected_repository: Option<&str>,
) -> Option<String> {
if let Some(user) = existing_oci_username.map(str::trim).filter(|s| !s.is_empty()) {
return Some(user.to_string());
}
github_repository
.and_then(github_owner_from_repository)
.or_else(|| detected_repository.and_then(github_owner_from_repository))
}
fn setup_oci(
project_root: &Path,
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 default_registry = config
.oci
.as_ref()
.and_then(|o| o.default_registry.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "ghcr.io".to_string());
let registry: String = Input::with_theme(theme)
.with_prompt("Registry host")
.with_initial_text(default_registry)
.interact_text()
.map_err(|e| e.to_string())?;
let existing_oci_user = config.oci.as_ref().and_then(|oci| {
oci.registries
.values()
.find_map(|r| r.username.as_deref().map(str::trim).filter(|s| !s.is_empty()))
.map(|s| s.to_string())
});
let github_repo = config
.github
.as_ref()
.and_then(|g| g.repository.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty());
let detected_repo = detect_github_repository(project_root);
let suggested_user = suggest_oci_registry_username(
existing_oci_user.as_deref(),
github_repo,
detected_repo.as_deref(),
)
.unwrap_or_default();
let username: String = Input::with_theme(theme)
.with_prompt("Registry username / GitHub org (package owner for image paths)")
.with_initial_text(suggested_user)
.allow_empty(true)
.interact_text()
.map_err(|e| e.to_string())?;
let default_package_repo = config
.oci
.as_ref()
.and_then(|o| o.repository.clone())
.or_else(|| {
let owner = username.trim();
if owner.is_empty() {
None
} else {
github_repo.map(|src| {
src.to_string()
})
}
})
.or_else(|| detected_repo.clone())
.unwrap_or_default();
let package_repo: String = Input::with_theme(theme)
.with_prompt(
"GitHub repository for OCI packages (owner/repo; may differ from source repo)",
)
.with_initial_text(default_package_repo)
.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 owner = {
let u = username.trim();
if !u.is_empty() {
Some(u.to_string())
} else {
package_repo
.trim()
.split_once('/')
.map(|(o, _)| o.trim().to_string())
.filter(|s| !s.is_empty())
}
};
let mut registries = HashMap::new();
registries.insert(
registry.trim().to_string(),
OciRegistryConfig {
provider: Some("github".into()),
username: owner.clone(),
token_source: Some(token_source.trim().to_string()),
url: None,
},
);
config.oci = Some(OciProjectConfig {
registries,
default_registry: Some(registry.trim().to_string()),
owner: owner.clone(),
repository: {
let r = package_repo.trim();
(!r.is_empty()).then(|| r.to_string())
},
});
notes.push(format!("oci.default_registry = {}", registry.trim()));
if let Some(o) = owner {
notes.push(format!("oci.owner = {o}"));
}
if let Some(r) = config.oci.as_ref().and_then(|o| o.repository.as_deref()) {
notes.push(format!("oci.repository = {r}"));
}
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,
});
let env_name = config
.deploy
.as_ref()
.and_then(|d| d.default_env.clone())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "production".to_string());
let selected = multi_select_services_for_deploy(
config,
theme,
"Kubernetes",
&format!(
"Which services should get deploy.envs.{env_name} (kubernetes)? Not all monorepo packages need a cluster workload."
),
false,
)?;
if !selected.is_empty() {
apply_k8s_deploy_env_to_services(config, &selected, &env_name, namespace.trim());
notes.push(format!(
"kubernetes deploy.envs.{env_name} on {} service(s)",
selected.len()
));
} else {
notes.push(
"kubernetes defaults saved (no services selected — set deploy.envs later)"
.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() {
print_picker_header(
"xbp deploy · groups",
&format!(
"{} discovered service(s). Multi-select which belong in deployable groups — skip libs, examples, and tooling.",
service_names.len()
),
);
let selected = multi_select_services_for_deploy(
config,
theme,
"deploy group",
"Services to include in deploy groups (space toggles). Leave none selected to skip groups.",
false,
)?;
if !selected.is_empty() {
let create_all = Confirm::with_theme(theme)
.with_prompt(format!(
"Create deploy group `all` with the {} selected service(s)?",
selected.len()
))
.default(true)
.interact()
.map_err(|e| e.to_string())?;
if create_all {
groups.insert(
"all".to_string(),
crate::strategies::deployment_config::DeployGroupConfig {
services: selected.clone(),
order: selected.clone(),
description: Some("Selected deployable services".into()),
},
);
}
if selected.len() > 3 {
let make_core = Confirm::with_theme(theme)
.with_prompt("Also create a smaller `core` group from a subset?")
.default(false)
.interact()
.map_err(|e| e.to_string())?;
if make_core {
let core = multi_select_from_names(
theme,
&selected,
"core group services",
"Subset for deploy group `core`",
false,
)?;
if !core.is_empty() {
groups.insert(
"core".to_string(),
crate::strategies::deployment_config::DeployGroupConfig {
services: core.clone(),
order: core,
description: Some("Core deployable services".into()),
},
);
}
}
}
notes.push(format!(
"deploy groups: {} ({} service(s) selected)",
groups.keys().cloned().collect::<Vec<_>>().join(", "),
selected.len()
));
} else {
notes.push("deploy groups skipped (no services selected)".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 multi_select_services_for_deploy(
config: &XbpConfig,
theme: &ColorfulTheme,
kind_label: &str,
subtitle: &str,
default_selected: bool,
) -> Result<Vec<String>, String> {
let services = config.services.as_deref().unwrap_or(&[]);
if services.is_empty() {
return Ok(Vec::new());
}
let labels: Vec<String> = services
.iter()
.map(|s| {
let provider = s
.deploy
.as_ref()
.and_then(|d| d.provider.as_deref())
.unwrap_or(s.target.as_str());
format!(
"{} · {} · :{}",
pad_col(&s.name, 36),
pad_col(provider, 14),
s.port
)
})
.collect();
let names: Vec<String> = services.iter().map(|s| s.name.clone()).collect();
print_picker_header(
&format!("xbp init · {kind_label} services"),
subtitle,
);
multi_select_labeled(theme, &names, &labels, &format!("{kind_label} services"), default_selected)
}
fn multi_select_from_names(
theme: &ColorfulTheme,
names: &[String],
prompt: &str,
subtitle: &str,
default_selected: bool,
) -> Result<Vec<String>, String> {
if !subtitle.is_empty() {
println!(" {}", subtitle.bright_black());
}
multi_select_labeled(theme, names, names, prompt, default_selected)
}
fn multi_select_labeled(
theme: &ColorfulTheme,
names: &[String],
labels: &[String],
prompt: &str,
default_selected: bool,
) -> Result<Vec<String>, String> {
if names.is_empty() {
return Ok(Vec::new());
}
debug_assert_eq!(names.len(), labels.len());
let defaults: Vec<bool> = names.iter().map(|_| default_selected).collect();
let chosen = MultiSelect::with_theme(theme)
.with_prompt(prompt)
.items(labels)
.defaults(&defaults)
.interact()
.map_err(|e| e.to_string())?;
Ok(chosen.into_iter().map(|i| names[i].clone()).collect())
}
fn apply_k8s_deploy_env_to_services(
config: &mut XbpConfig,
service_names: &[String],
env_name: &str,
namespace: &str,
) {
use crate::strategies::deployment_config::{
ServiceDeployConfig, ServiceDeployEnvConfig, ServiceDeployKubernetesConfig,
};
let Some(services) = config.services.as_mut() else {
return;
};
let selected: BTreeSet<&str> = service_names.iter().map(|s| s.as_str()).collect();
for service in services.iter_mut() {
if !selected.contains(service.name.as_str()) {
continue;
}
let deploy = service.deploy.get_or_insert_with(ServiceDeployConfig::default);
if deploy.provider.is_none() {
deploy.provider = Some("kubernetes".into());
}
deploy.envs.entry(env_name.to_string()).or_insert_with(|| {
ServiceDeployEnvConfig {
namespace: Some(namespace.to_string()).filter(|s| !s.is_empty()),
replicas: Some(1),
health: Vec::new(),
kubernetes: Some(ServiceDeployKubernetesConfig {
workload: Some(format!("deploy/{}", service.name)),
service: Some(service.name.clone()),
selector: Some(format!("app={}", service.name)),
..Default::default()
}),
..Default::default()
}
});
}
}
const DISCORD_WEBHOOK_ENV_KEYS: &[&str] = &["DISCORD_WEBHOOK_URL"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiscordWebhookEnvSource {
Dotenv { path: PathBuf },
ProcessEnv,
}
pub fn find_existing_discord_webhook_url(
project_root: &Path,
) -> Option<(String, DiscordWebhookEnvSource)> {
if let Some((value, path)) =
first_dotenv_value_upwards(project_root, DISCORD_WEBHOOK_ENV_KEYS, 8)
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some((
trimmed.to_string(),
DiscordWebhookEnvSource::Dotenv { path },
));
}
}
if let Ok(value) = std::env::var("DISCORD_WEBHOOK_URL") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some((trimmed.to_string(), DiscordWebhookEnvSource::ProcessEnv));
}
}
None
}
pub fn mask_discord_webhook_url(url: &str) -> String {
let chars: Vec<char> = url.chars().collect();
if chars.is_empty() {
return "(empty)".to_string();
}
if chars.len() <= 8 {
return "*".repeat(chars.len());
}
let prefix: String = chars.iter().take(4).collect();
let suffix: String = chars.iter().skip(chars.len() - 4).collect();
format!("{prefix}...{suffix}")
}
fn format_discord_webhook_env_source(
project_root: &Path,
source: &DiscordWebhookEnvSource,
) -> String {
match source {
DiscordWebhookEnvSource::Dotenv { path } => {
collapse_project_path(project_root, &path.to_string_lossy())
}
DiscordWebhookEnvSource::ProcessEnv => "process environment".to_string(),
}
}
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 existing_webhook = find_existing_discord_webhook_url(project_root);
if let Some((url, source)) = existing_webhook.as_ref() {
let where_found = format_discord_webhook_env_source(project_root, source);
println!(
" {} {} already set in {} ({})",
"✓".green(),
"DISCORD_WEBHOOK_URL".bright_white(),
where_found.bright_cyan(),
mask_discord_webhook_url(url).dimmed()
);
}
let env_mode_label = if existing_webhook.is_some() {
let where_found = existing_webhook
.as_ref()
.map(|(_, s)| format_discord_webhook_env_source(project_root, s))
.unwrap_or_else(|| env_label.clone());
format!("Env placeholder ${{DISCORD_WEBHOOK_URL}} (already set in {where_found})")
} else {
format!("Env placeholder ${{DISCORD_WEBHOOK_URL}} (writes {env_label})")
};
let modes = vec![
format!("Project webhook URL (stored in {config_path})"),
env_mode_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 keep_existing = if let Some((url, source)) = existing_webhook.as_ref() {
let where_found = format_discord_webhook_env_source(project_root, source);
Confirm::with_theme(theme)
.with_prompt(format!(
"Keep existing DISCORD_WEBHOOK_URL from {where_found} ({})?",
mask_discord_webhook_url(url)
))
.default(true)
.interact()
.map_err(|e| e.to_string())?
} else {
false
};
if keep_existing {
notes.push(format!(
"DISCORD_WEBHOOK_URL already set ({}); left unchanged",
existing_webhook
.as_ref()
.map(|(_, s)| format_discord_webhook_env_source(project_root, s))
.unwrap_or_else(|| env_label.clone())
));
} else {
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 existing_key = resolve_linear_api_key();
let api_key = if let Some(key) = existing_key {
println!(
" {} Linear API key already configured (global/project)",
"✓".bright_green().bold()
);
Some(key)
} else {
let ensure_key = Confirm::with_theme(theme)
.with_prompt("Linear API key not found — configure one now?")
.default(true)
.interact()
.map_err(|e| e.to_string())?;
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();
print_picker_header(
"xbp linear · initiative",
"Release changelogs post here during `xbp version release`",
);
let selection = dialoguer::FuzzySelect::with_theme(&xbp_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 = prompt_default_linear_team_key(api_key.as_deref(), theme).await?;
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(", ")
));
}
if !team_key.trim().is_empty() {
notes.push(format!("linear.default_team_key = {}", team_key.trim()));
}
Ok(())
}
}
#[cfg(feature = "linear")]
async fn prompt_default_linear_team_key(
api_key: Option<&str>,
theme: &ColorfulTheme,
) -> Result<String, String> {
if let Some(api_key) = api_key {
match crate::commands::linear_cmd::list_teams(api_key).await {
Ok(teams) if !teams.is_empty() => {
let mut labels: Vec<String> = teams
.iter()
.map(|t| format!("{} — {}", pad_col(&t.key, 8), t.name))
.collect();
labels.push("(skip — no default team)".to_string());
print_picker_header(
"xbp linear · default team",
"Used by `xbp linear create` when --team is omitted · type to filter",
);
let idx = FuzzySelect::with_theme(&xbp_theme())
.with_prompt("Default Linear team key")
.items(&labels)
.default(0)
.interact_opt()
.map_err(|e| e.to_string())?;
return Ok(match idx {
Some(i) if i < teams.len() => teams[i].key.clone(),
_ => String::new(),
});
}
Ok(_) => {
println!(
" {}",
"No Linear teams found for this API key.".yellow()
);
}
Err(err) => {
println!(
" {} {}",
"Could not list Linear teams:".yellow(),
err.dimmed()
);
}
}
}
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())
}
#[cfg(test)]
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")));
for root in &roots {
let rel = collapse_project_path(&dir, &root.to_string_lossy());
assert!(
!rel.contains(':') && !rel.starts_with('/'),
"expected relative path, got {rel}"
);
assert!(rel.starts_with("apps/") || rel == "./");
}
let _ = fs::remove_dir_all(dir);
}
#[test]
fn discover_publish_packages_finds_cargo_and_npm() {
let dir = temp_dir("publish-pkgs");
fs::create_dir_all(dir.join("crates/a")).unwrap();
fs::create_dir_all(dir.join("crates/b")).unwrap();
fs::create_dir_all(dir.join("packages/ui")).unwrap();
fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n",
)
.unwrap();
fs::write(
dir.join("crates/a/Cargo.toml"),
"[package]\nname = \"pkg_a\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::write(
dir.join("crates/b/Cargo.toml"),
"[package]\nname = \"pkg_b\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::write(
dir.join("packages/ui/package.json"),
r#"{"name":"@acme/ui","version":"1.0.0"}"#,
)
.unwrap();
fs::write(
dir.join("package.json"),
r#"{"name":"root-app","private":true,"version":"0.0.0"}"#,
)
.unwrap();
let crates = discover_publish_packages(&dir, "crates");
let names: Vec<_> = crates.iter().map(|p| p.package_name.as_str()).collect();
assert!(names.contains(&"pkg_a"), "{names:?}");
assert!(names.contains(&"pkg_b"), "{names:?}");
assert!(
!names.iter().any(|n| n.is_empty()),
"virtual workspace must not appear"
);
assert!(crates.iter().any(|p| p.manifest_path == "crates/a/Cargo.toml"));
let npm = discover_publish_packages(&dir, "npm");
let npm_names: Vec<_> = npm.iter().map(|p| p.package_name.as_str()).collect();
assert!(npm_names.contains(&"@acme/ui"), "{npm_names:?}");
assert!(npm_names.contains(&"root-app"), "{npm_names:?}");
assert!(npm.iter().any(|p| p.manifest_path == "packages/ui/package.json"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn publish_target_package_entries_expand_monorepo_list() {
let cfg = PublishTargetConfig {
package_name: Some("primary".into()),
manifest_path: Some("crates/primary/Cargo.toml".into()),
packages: vec![
PublishPackageEntry {
package_name: Some("a".into()),
manifest_path: Some("crates/a/Cargo.toml".into()),
working_directory: Some("crates/a".into()),
},
PublishPackageEntry {
package_name: Some("b".into()),
manifest_path: Some("crates/b/Cargo.toml".into()),
working_directory: Some("crates/b".into()),
},
],
..Default::default()
};
let entries = cfg.package_entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].package_name.as_deref(), Some("a"));
let scoped = cfg.for_package_entry(&entries[1]);
assert_eq!(scoped.package_name.as_deref(), Some("b"));
assert!(scoped.packages.is_empty());
}
#[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 github_owner_from_repository_extracts_owner() {
assert_eq!(
github_owner_from_repository("suitsfinance/xbp").as_deref(),
Some("suitsfinance")
);
assert_eq!(
github_owner_from_repository(" xylex-group/athena ").as_deref(),
Some("xylex-group")
);
assert_eq!(github_owner_from_repository("solo"), None);
assert_eq!(github_owner_from_repository(""), None);
assert_eq!(github_owner_from_repository("/"), None);
}
#[test]
fn suggest_oci_registry_username_prefers_github_then_detected() {
assert_eq!(
suggest_oci_registry_username(Some("existing"), Some("org/repo"), Some("other/r"))
.as_deref(),
Some("existing")
);
assert_eq!(
suggest_oci_registry_username(None, Some("suitsfinance/app"), Some("other/r"))
.as_deref(),
Some("suitsfinance")
);
assert_eq!(
suggest_oci_registry_username(None, None, Some("detected-org/repo")).as_deref(),
Some("detected-org")
);
assert_eq!(suggest_oci_registry_username(None, None, None), None);
assert_eq!(
suggest_oci_registry_username(Some(" "), Some("acme/x"), None).as_deref(),
Some("acme")
);
}
#[test]
fn mask_discord_webhook_url_hides_middle() {
assert_eq!(mask_discord_webhook_url(""), "(empty)");
assert_eq!(mask_discord_webhook_url("short"), "*****");
assert_eq!(
mask_discord_webhook_url("https://discord.com/api/webhooks/123/abc"),
"http.../abc"
);
}
#[test]
fn find_existing_discord_webhook_url_reads_dotenv() {
let root = std::env::temp_dir().join(format!(
"xbp-discord-env-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::write(
root.join(".env.local"),
"DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1/token\n",
)
.unwrap();
let found = find_existing_discord_webhook_url(&root).expect("webhook");
assert_eq!(
found.0.as_str(),
"https://discord.com/api/webhooks/1/token"
);
match found.1 {
DiscordWebhookEnvSource::Dotenv { path } => {
assert!(path.ends_with(".env.local"));
}
other => panic!("expected dotenv source, got {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
#[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));
}
}