use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
use crate::cli::interactive::{
confirm, print_picker_header, searchable_multi_select, searchable_select_required, select_one,
xbp_theme,
};
use crate::commands::init_wizard::run_init_setup_wizard;
use crate::commands::project_services::{
auto_populate_services, discover_service_version_targets_for_project,
};
use crate::strategies::deployment_config::{
ServiceDeployConfig, ServiceDeployDestinationConfig, XbpConfig,
};
use crate::strategies::project_detector::{
infer_project_name as shared_infer_project_name, DeploymentRecommendations, PackageJsonInfo,
ProjectDetector, ProjectType,
};
use crate::strategies::{
legacy_service_from_config, normalize_config_paths_for_persistence, validate_services,
DeploymentConfig, ServiceCommands, ServiceConfig,
};
use crate::utils::{
collapse_project_path, default_project_config_path, find_xbp_config_upwards,
load_project_xbp_ignore, migrate_package_json_pnpm_settings, parse_env_file,
resolve_project_config_write_path, to_env_references, FoundXbpConfig,
DEFAULT_PROJECT_CONFIG_KIND, DEFAULT_PROJECT_CONFIG_RELATIVE,
};
use colored::Colorize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::{env, fs};
use dialoguer::Input;
use regex::Regex;
use tokio::process::Command;
use tracing::debug;
const SERVICE_DISCOVERY_MARKERS: &[&str] = &[
"package.json",
"Cargo.toml",
"pyproject.toml",
"requirements.txt",
"setup.py",
"Dockerfile",
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
"railway.json",
"railway.toml",
"vercel.json",
"go.mod",
];
const SERVICE_VERSION_MANIFESTS: &[&str] = &[
"package.json",
"Cargo.toml",
"pyproject.toml",
"composer.json",
"deno.json",
"deno.jsonc",
"Chart.yaml",
"app.json",
"manifest.json",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
pub async fn run_init(_debug: bool) -> Result<(), String> {
let current_dir: PathBuf =
env::current_dir().map_err(|e| format!("Failed to read current directory: {}", e))?;
print_picker_header(
"xbp init",
"Project config wizard — app type, deploy targets, then full setup modules",
);
let existing = find_xbp_config_upwards(¤t_dir);
let mut write_preferred_toml = existing.is_none();
if let Some(found) = existing.as_ref() {
if found.project_root != current_dir {
return run_nested_service_init(found.clone(), current_dir).await;
}
print_picker_header(
"Existing XBP config",
&format!("{}", found.location),
);
let choices = [
"Open setup wizard (release, Discord, publish, Workers, …)",
"Re-detect project and overwrite base config as .xbp/xbp.toml, then open wizard",
"Cancel",
];
let choice = select_one("What next?", &choices, 0)?
.ok_or_else(|| "Selection cancelled".to_string())?;
match choice {
0 => {
return finish_init_with_wizard(
&found.project_root,
&found.config_path,
DeploymentConfig::load_xbp_config(Some(found.config_path.clone())).await?,
vec![found.config_path.clone()],
)
.await;
}
1 => {
write_preferred_toml = true;
}
_ => return Ok(()),
}
}
let project_type: ProjectType = ProjectDetector::detect_project_type(¤t_dir)
.await
.unwrap_or(ProjectType::Unknown);
debug!(?project_type, "Detected project type");
let recommendations: DeploymentRecommendations =
ProjectDetector::get_deployment_recommendations(¤t_dir, &project_type);
let inferred_name: String = infer_project_name(&project_type, ¤t_dir, &recommendations);
let app_type_guess: Option<String> = infer_app_type(¤t_dir, &project_type);
let deploy_guess: Vec<String> = infer_deploy_targets(¤t_dir, &project_type);
let port_guess: u16 = detect_port(¤t_dir, &project_type, &recommendations);
let env_vars: HashMap<String, String> = detect_environment_from_env_files(¤t_dir);
let theme = xbp_theme();
let project_name: String = Input::with_theme(&theme)
.with_prompt("Project name")
.with_initial_text(inferred_name)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let app_type: String = select_app_type(app_type_guess.as_deref())?;
let deploy_targets: Vec<String> = select_deploy_targets(&deploy_guess)?;
let port: u16 = Input::with_theme(&theme)
.with_prompt("Primary port")
.default(port_guess)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let build_dir: String = collapse_project_path(¤t_dir, ¤t_dir.to_string_lossy());
let mut config: XbpConfig = XbpConfig {
project_name,
version: "0.1.0".to_string(),
port,
build_dir,
app_type: Some(app_type.clone()),
build_command: recommendations.build_command.clone(),
start_command: recommendations.start_command.clone(),
install_command: recommendations.install_command.clone(),
environment: if env_vars.is_empty() {
None
} else {
Some(env_vars)
},
services: None,
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: None,
target: Some(app_type),
branch: current_git_branch().await,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: Vec::new(),
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
versioning: None,
discord: None,
audit: None,
};
auto_populate_services(&mut config, ¤t_dir, &project_type).await?;
apply_deploy_targets_to_config(&mut config, &deploy_targets);
let config_path: PathBuf = if write_preferred_toml {
default_project_config_path(¤t_dir, DEFAULT_PROJECT_CONFIG_KIND)
} else {
resolve_project_config_write_path(¤t_dir, existing.as_ref())
};
let mut written_paths: Vec<PathBuf> = write_configs(&config, ¤t_dir, &config_path)?;
if write_preferred_toml {
if let Some(retired) = retire_non_primary_project_configs(¤t_dir, &config_path) {
println!(
"{} {} → {}",
"migrated".bright_cyan().bold(),
retired.display().to_string().dimmed(),
DEFAULT_PROJECT_CONFIG_RELATIVE.bright_white()
);
}
}
match migrate_package_json_pnpm_settings(¤t_dir) {
Ok(migration) if !migration.changed_paths.is_empty() => {
for note in &migration.notes {
println!("{} {}", "pnpm".bright_cyan().bold(), note.bright_white());
}
written_paths.extend(migration.changed_paths);
}
Ok(_) => {}
Err(error) => {
println!(
"{} {}",
"pnpm".bright_yellow().bold(),
format!("package.json migration skipped: {error}").dimmed()
);
}
}
let legacy_json_path: PathBuf = config_path
.parent()
.map(|parent| parent.join("xbp.json"))
.ok_or_else(|| "Invalid project config path".to_string())?;
if legacy_json_path.exists() && legacy_json_path != config_path {
println!(
"Created {} (synced legacy {})",
config_path.display(),
legacy_json_path.display()
);
} else {
println!("Created {}", config_path.display());
}
finish_init_with_wizard(¤t_dir, &config_path, config, written_paths).await
}
async fn finish_init_with_wizard(
project_root: &Path,
config_path: &Path,
mut config: XbpConfig,
mut written_paths: Vec<PathBuf>,
) -> Result<(), String> {
if !written_paths.iter().any(|p| p == config_path) {
written_paths.push(config_path.to_path_buf());
}
match run_init_setup_wizard(project_root, &mut config).await {
Ok(extra_paths) => {
for p in extra_paths {
let path = PathBuf::from(&p);
if !written_paths.iter().any(|existing| existing == &path) {
written_paths.push(path);
}
}
}
Err(error) => {
println!(
"{} {}",
"wizard".bright_yellow().bold(),
format!("full setup failed: {error}").dimmed()
);
write_configs(&config, project_root, config_path)?;
return Err(error);
}
}
match commit_paths(AutoCommitRequest {
project_root,
paths: written_paths,
message: "chore(xbp): initialize project config".to_string(),
action_label: "xbp init",
push: false,
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {}
Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
Err(e) => print_skip("xbp init", &e),
}
Ok(())
}
#[derive(Debug, Clone)]
struct NestedServiceCandidate {
service_root: PathBuf,
project_type: ProjectType,
}
async fn run_nested_service_init(
found: FoundXbpConfig,
current_dir: PathBuf,
) -> Result<(), String> {
let candidate = resolve_nested_service_candidate(&found.project_root, ¤t_dir)
.await?
.ok_or_else(|| {
format!(
"Found existing XBP project at {}, but no nested package/service markers were found between {} and the project root. Run `xbp init` from a folder that contains a package manifest such as package.json, Cargo.toml, or pyproject.toml.",
found.project_root.display(),
current_dir.display()
)
})?;
let recommendations: DeploymentRecommendations =
ProjectDetector::get_deployment_recommendations(
&candidate.service_root,
&candidate.project_type,
);
let inferred_name: String = infer_project_name(
&candidate.project_type,
&candidate.service_root,
&recommendations,
);
let app_type_guess: Option<String> =
infer_app_type(&candidate.service_root, &candidate.project_type);
let deploy_guess: Vec<String> =
infer_deploy_targets(&candidate.service_root, &candidate.project_type);
let port_guess: u16 = detect_port(
&candidate.service_root,
&candidate.project_type,
&recommendations,
);
let env_vars: HashMap<String, String> = detect_environment_from_env_files(&candidate.service_root);
let version_targets: Vec<String> = discover_service_version_targets_for_project(
&candidate.service_root,
&found.project_root,
&[],
);
print_picker_header(
"Nested service",
&format!(
"Register `{}` under {}",
collapse_project_path(&found.project_root, &candidate.service_root.to_string_lossy()),
found.project_root.display()
),
);
let theme = xbp_theme();
let service_name: String = Input::with_theme(&theme)
.with_prompt("Service name")
.with_initial_text(inferred_name)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let app_type: String = select_app_type(app_type_guess.as_deref())?;
let deploy_targets: Vec<String> = select_deploy_targets(&deploy_guess)?;
let port: u16 = Input::with_theme(&theme)
.with_prompt("Primary port")
.default(port_guess)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let versioning_enabled: bool =
confirm("Enable versioning for this service? (xbp version bump)", true)?;
let release_enabled = if versioning_enabled {
confirm(
"Enable releasing for this service? (xbp version release)",
true,
)?
} else {
false
};
let service_root_relative: String = collapse_project_path(
&found.project_root,
&candidate.service_root.to_string_lossy(),
);
let mut service_deploy = ServiceDeployConfig::default();
apply_deploy_targets_to_service_deploy(&mut service_deploy, &deploy_targets);
let service_config: ServiceConfig = ServiceConfig {
name: service_name.clone(),
target: app_type.clone(),
target_freeze: None,
branch: current_git_branch()
.await
.unwrap_or_else(|| "main".to_string()),
port,
root_directory: Some(service_root_relative.clone()),
environment: if env_vars.is_empty() {
None
} else {
Some(env_vars)
},
url: None,
healthcheck_path: None,
restart_policy: Some("on_failure".to_string()),
restart_policy_max_failure_count: Some(10),
start_wrapper: Some("pm2".to_string()),
commands: Some(ServiceCommands {
pre: None,
install: recommendations.install_command.clone(),
build: recommendations.build_command.clone(),
start: recommendations.start_command.clone(),
dev: None,
custom: Default::default(),
}),
force_run_from_root: Some(false),
version_targets: if version_targets.is_empty() || !versioning_enabled {
None
} else {
Some(version_targets.clone())
},
depends_on: None,
watch_paths: None,
versioning: if versioning_enabled {
None
} else {
Some(false)
},
release: if release_enabled { None } else { Some(false) },
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: if deploy_targets.is_empty() {
None
} else {
Some(service_deploy)
},
discord: None,
file_associations: Vec::new(),
};
let mut config: XbpConfig = DeploymentConfig::load_xbp_config(Some(found.config_path.clone())).await?;
ensure_root_service_entry(&mut config, &found.project_root, &version_targets);
upsert_service_config(
&mut config,
service_config,
&service_root_relative,
&version_targets,
);
merge_project_version_targets(&mut config, &found.project_root, &version_targets);
if let Some(services) = &config.services {
validate_services(services)?;
}
let config_path = found.config_path.clone();
let written_paths: Vec<PathBuf> = write_configs(&config, &found.project_root, &config_path)?;
println!(
"Updated {} and registered nested service `{}` at {}",
config_path.display(),
service_name,
service_root_relative
);
match commit_paths(AutoCommitRequest {
project_root: &found.project_root,
paths: written_paths,
message: format!("chore(xbp): register service {}", service_name),
action_label: "xbp init",
push: false,
})
.await
{
Ok(AutoCommitResult::Committed(_)) => {}
Ok(AutoCommitResult::Skipped(reason)) => print_skip("xbp init", &reason),
Err(e) => print_skip("xbp init", &e),
}
Ok(())
}
fn infer_project_name(
project_type: &ProjectType,
current_dir: &Path,
recommendations: &DeploymentRecommendations,
) -> String {
shared_infer_project_name(current_dir, project_type, recommendations)
}
#[derive(Debug, Clone, Copy)]
struct AppTypeOption {
id: &'static str,
label: &'static str,
}
#[derive(Debug, Clone, Copy)]
struct DeployTargetOption {
id: &'static str,
label: &'static str,
provider: &'static str,
}
fn app_type_catalog() -> &'static [AppTypeOption] {
&[
AppTypeOption {
id: "nextjs",
label: "Next.js · React full-stack",
},
AppTypeOption {
id: "tanstack-start",
label: "TanStack Start · full-stack React",
},
AppTypeOption {
id: "remix",
label: "Remix · React-based",
},
AppTypeOption {
id: "astro",
label: "Astro · content / islands",
},
AppTypeOption {
id: "hono",
label: "Hono · Node web framework",
},
AppTypeOption {
id: "expressjs",
label: "Express · Node API",
},
AppTypeOption {
id: "nodejs",
label: "Node.js · generic JS/TS",
},
AppTypeOption {
id: "deno",
label: "Deno · TS runtime",
},
AppTypeOption {
id: "electron",
label: "Electron · desktop (Chromium)",
},
AppTypeOption {
id: "react-native",
label: "React Native · mobile",
},
AppTypeOption {
id: "tauri",
label: "Tauri · desktop (Rust shell)",
},
AppTypeOption {
id: "swiftui",
label: "SwiftUI · Apple platforms",
},
AppTypeOption {
id: "rust-axum",
label: "Rust · Axum API · async HTTP",
},
AppTypeOption {
id: "rust-actix",
label: "Rust · Actix-web API · actor HTTP",
},
AppTypeOption {
id: "rust",
label: "Rust · generic / other crate",
},
AppTypeOption {
id: "go",
label: "Go · modules / HTTP",
},
AppTypeOption {
id: "python",
label: "Python · FastAPI / scripts",
},
AppTypeOption {
id: "docker",
label: "Docker · single image",
},
AppTypeOption {
id: "docker-compose",
label: "Docker Compose · multi-service",
},
]
}
fn deploy_target_catalog() -> &'static [DeployTargetOption] {
&[
DeployTargetOption {
id: "kubernetes",
label: "Kubernetes · cluster / docker-desktop",
provider: "kubernetes",
},
DeployTargetOption {
id: "worker",
label: "Cloudflare Worker · Workers (script)",
provider: "worker",
},
DeployTargetOption {
id: "cloudflare-containers",
label: "Cloudflare Containers · Worker + container",
provider: "cloudflare-containers",
},
DeployTargetOption {
id: "vercel",
label: "Vercel · platform deploy",
provider: "vercel",
},
DeployTargetOption {
id: "railway",
label: "Railway · platform deploy",
provider: "railway",
},
DeployTargetOption {
id: "local",
label: "Local · process / local OCI",
provider: "local",
},
DeployTargetOption {
id: "docker",
label: "Docker host · docker run / compose",
provider: "docker",
},
]
}
fn infer_app_type(project_root: &Path, project_type: &ProjectType) -> Option<String> {
if let Some(from_pkg) = infer_app_type_from_package_json(project_root) {
return Some(from_pkg);
}
if project_root.join("deno.json").exists() || project_root.join("deno.jsonc").exists() {
return Some("deno".to_string());
}
if project_root.join("src-tauri").exists() || project_root.join("tauri.conf.json").exists() {
return Some("tauri".to_string());
}
if project_root.join("go.mod").exists() {
return Some("go".to_string());
}
if let Some(rust) = infer_rust_app_type(project_root) {
return Some(rust);
}
match project_type {
ProjectType::NextJs { .. } => Some("nextjs".to_string()),
ProjectType::NodeJs { package_json } => {
if has_dep(package_json, "express") {
Some("expressjs".to_string())
} else {
Some("nodejs".to_string())
}
}
ProjectType::Rust { .. } => Some("rust".to_string()),
ProjectType::DockerCompose { .. } => Some("docker-compose".to_string()),
ProjectType::Docker { .. } => Some("docker".to_string()),
ProjectType::Railway { .. } | ProjectType::Vercel { .. } => {
if project_root.join("package.json").exists() {
Some("nodejs".to_string())
} else {
None
}
}
ProjectType::Python { .. } => Some("python".to_string()),
_ => None,
}
}
fn infer_app_type_from_package_json(project_root: &Path) -> Option<String> {
let pkg_path = project_root.join("package.json");
let content = fs::read_to_string(pkg_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
let deps = merge_pkg_dep_names(&value);
if deps.iter().any(|d| d == "next") {
return Some("nextjs".to_string());
}
if deps.iter().any(|d| d == "@tanstack/react-start" || d == "@tanstack/start") {
return Some("tanstack-start".to_string());
}
if deps.iter().any(|d| d == "@remix-run/node" || d == "@remix-run/react" || d == "remix") {
return Some("remix".to_string());
}
if deps.iter().any(|d| d == "astro") {
return Some("astro".to_string());
}
if deps.iter().any(|d| d == "hono") {
return Some("hono".to_string());
}
if deps.iter().any(|d| d == "electron") {
return Some("electron".to_string());
}
if deps.iter().any(|d| d == "react-native") {
return Some("react-native".to_string());
}
if deps.iter().any(|d| d == "@tauri-apps/api" || d == "@tauri-apps/cli") {
return Some("tauri".to_string());
}
if deps.iter().any(|d| d == "express") {
return Some("expressjs".to_string());
}
None
}
fn merge_pkg_dep_names(value: &serde_json::Value) -> HashSet<String> {
let mut names = HashSet::new();
for key in ["dependencies", "devDependencies", "peerDependencies"] {
if let Some(map) = value.get(key).and_then(|v| v.as_object()) {
for name in map.keys() {
names.insert(name.to_ascii_lowercase());
}
}
}
names
}
fn infer_rust_app_type(project_root: &Path) -> Option<String> {
let cargo = project_root.join("Cargo.toml");
if !cargo.exists() {
return None;
}
let text = fs::read_to_string(cargo).ok()?;
let lower = text.to_ascii_lowercase();
if lower.contains("axum") {
Some("rust-axum".to_string())
} else if lower.contains("actix-web") {
Some("rust-actix".to_string())
} else if project_root.join("Cargo.toml").exists() {
Some("rust".to_string())
} else {
None
}
}
fn infer_deploy_targets(project_root: &Path, project_type: &ProjectType) -> Vec<String> {
let mut out = Vec::new();
let mut push = |id: &str| {
if !out.iter().any(|e| e == id) {
out.push(id.to_string());
}
};
match project_type {
ProjectType::Railway { .. } => push("railway"),
ProjectType::Vercel { .. } => push("vercel"),
ProjectType::DockerCompose { .. } | ProjectType::Docker { .. } => push("docker"),
_ => {}
}
if project_root.join("railway.json").exists() || project_root.join("railway.toml").exists() {
push("railway");
}
if project_root.join("vercel.json").exists() {
push("vercel");
}
if project_root.join("wrangler.toml").exists()
|| project_root.join("wrangler.jsonc").exists()
|| project_root.join("wrangler.json").exists()
{
if project_root.join("Dockerfile").exists() {
push("cloudflare-containers");
} else {
push("worker");
}
}
if project_root.join("k8s").exists()
|| project_root.join("deploy/k8s").exists()
|| project_root.join("charts").exists()
|| project_root.join("Chart.yaml").exists()
{
push("kubernetes");
}
out
}
fn has_dep(package_json: &PackageJsonInfo, name: &str) -> bool {
package_json
.dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case(name))
|| package_json
.dev_dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case(name))
}
fn select_app_type(detected: Option<&str>) -> Result<String, String> {
let catalog = app_type_catalog();
let mut labels: Vec<String> = catalog
.iter()
.map(|opt| {
if detected.is_some_and(|d| d == opt.id) {
format!("{} · detected", opt.label.trim_end())
} else {
opt.label.to_string()
}
})
.collect();
labels.push("Custom… · free-form id".to_string());
let default_index = detected
.and_then(|d| catalog.iter().position(|o| o.id == d))
.unwrap_or(0);
print_picker_header(
"App type",
"Framework / runtime of this project (not where it deploys)",
);
let selection = searchable_select_required("App type", &labels, default_index)?;
if selection >= catalog.len() {
let theme = xbp_theme();
return Input::<String>::with_theme(&theme)
.with_prompt("Enter app type id (e.g. nestjs, solid-start)")
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))
.map(|s| s.trim().to_ascii_lowercase());
}
Ok(catalog[selection].id.to_string())
}
fn select_deploy_targets(detected: &[String]) -> Result<Vec<String>, String> {
let catalog = deploy_target_catalog();
let labels: Vec<String> = catalog
.iter()
.map(|opt| {
if detected.iter().any(|d| d == opt.id) {
format!("{} · detected", opt.label.trim_end())
} else {
opt.label.to_string()
}
})
.collect();
let defaults: Vec<bool> = catalog
.iter()
.map(|opt| detected.iter().any(|d| d == opt.id))
.collect();
print_picker_header(
"Deploy targets",
"Where this app ships (space to toggle). Vercel/Railway/K8s/Workers live here — not under App type.",
);
let chosen = searchable_multi_select("Deploy targets", &labels, &defaults)?;
Ok(chosen
.into_iter()
.filter_map(|i| catalog.get(i).map(|o| o.id.to_string()))
.collect())
}
fn resolve_deploy_provider(target_id: &str) -> Option<&'static str> {
deploy_target_catalog()
.iter()
.find(|o| o.id == target_id)
.map(|o| o.provider)
}
fn app_type_ids() -> Vec<&'static str> {
app_type_catalog().iter().map(|o| o.id).collect()
}
fn deploy_target_ids() -> Vec<&'static str> {
deploy_target_catalog().iter().map(|o| o.id).collect()
}
fn apply_deploy_targets_to_config(config: &mut XbpConfig, targets: &[String]) {
if targets.is_empty() {
return;
}
if let Some(services) = config.services.as_mut() {
for service in services.iter_mut() {
let deploy = service.deploy.get_or_insert_with(ServiceDeployConfig::default);
apply_deploy_targets_to_service_deploy(deploy, targets);
}
}
}
fn apply_deploy_targets_to_service_deploy(deploy: &mut ServiceDeployConfig, targets: &[String]) {
if targets.is_empty() {
return;
}
let primary = targets
.first()
.and_then(|id| resolve_deploy_provider(id))
.map(str::to_string);
if deploy.provider.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
deploy.provider = primary;
}
for id in targets {
let Some(provider) = resolve_deploy_provider(id) else {
continue;
};
let key = id.replace('_', "-");
deploy.destinations.entry(key).or_insert_with(|| {
ServiceDeployDestinationConfig {
provider: Some(provider.to_string()),
enabled: Some(true),
..Default::default()
}
});
}
}
fn detect_port(
project_root: &Path,
project_type: &ProjectType,
recommendations: &DeploymentRecommendations,
) -> u16 {
if let Ok(port_env) = env::var("PORT") {
if let Ok(port) = port_env.parse::<u16>() {
return port;
}
}
for name in [".env", ".env.local", ".env.development", ".env.production"] {
if let Some(port) = parse_port_from_env_file(&project_root.join(name)) {
return port;
}
}
if let Some(port) = detect_port_from_package_json(project_root) {
return port;
}
if let ProjectType::DockerCompose { detected_ports, .. } = project_type {
if let Some(port) = detected_ports.first() {
return *port;
}
}
recommendations.default_port
}
fn parse_port_from_env_file(path: &Path) -> Option<u16> {
if let Ok(parsed) = parse_env_file(path) {
if let Some(port) = parsed
.get("PORT")
.and_then(|value| value.parse::<u16>().ok())
{
return Some(port);
}
}
let contents = fs::read_to_string(path).ok()?;
for line in contents.lines() {
if let Some(port) = extract_port_from_str(line.trim()) {
return Some(port);
}
}
None
}
fn detect_port_from_package_json(project_root: &Path) -> Option<u16> {
let pkg_path = project_root.join("package.json");
let content = fs::read_to_string(&pkg_path).ok()?;
let value: serde_json::Value = serde_json::from_str(&content).ok()?;
if let Some(port) = value.get("port").and_then(|v| v.as_u64()) {
return Some(port as u16);
}
if let Some(scripts) = value.get("scripts").and_then(|v| v.as_object()) {
for script in scripts.values() {
if let Some(text) = script.as_str() {
if let Some(port) = extract_port_from_str(text) {
return Some(port);
}
}
}
}
None
}
fn extract_port_from_str(text: &str) -> Option<u16> {
let patterns = [
r"PORT\s*[:=]\s*(\d{2,5})",
r"port\s*[:=]\s*(\d{2,5})",
r"--port\s+(\d{2,5})",
r"-p\s+(\d{2,5})",
];
for pat in patterns {
if let Ok(re) = Regex::new(pat) {
if let Some(caps) = re.captures(text) {
if let Some(m) = caps.get(1) {
if let Ok(port) = m.as_str().parse::<u16>() {
return Some(port);
}
}
}
}
}
None
}
fn detect_environment_from_env_files(project_root: &Path) -> HashMap<String, String> {
let mut env_map = HashMap::new();
for name in [".env", ".env.local", ".env.development", ".env.production"] {
let path = project_root.join(name);
if !path.exists() {
continue;
}
if let Ok(parsed) = parse_env_file(&path) {
for (key, value) in parsed {
env_map.entry(key).or_insert(value);
}
}
}
to_env_references(&env_map)
}
fn write_configs(
config: &XbpConfig,
project_root: &Path,
config_path: &Path,
) -> Result<Vec<PathBuf>, String> {
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
}
let mut persisted = config.clone();
normalize_config_paths_for_persistence(&mut persisted, project_root);
crate::utils::write_xbp_project_config_at_path(config_path, &persisted)?;
let mut written_paths = vec![config_path.to_path_buf()];
let json_path = config_path
.parent()
.map(|parent| parent.join("xbp.json"))
.ok_or_else(|| "Invalid project config path".to_string())?;
if json_path.exists() && json_path != config_path {
crate::utils::write_xbp_project_config_at_path(&json_path, &persisted)
.map_err(|e| format!("Failed to write {}: {}", json_path.display(), e))?;
written_paths.push(json_path);
}
Ok(written_paths)
}
fn retire_non_primary_project_configs(
project_root: &Path,
primary: &Path,
) -> Option<PathBuf> {
let primary_canon = primary.canonicalize().unwrap_or_else(|_| primary.to_path_buf());
let mut retired = None;
for name in ["xbp.yaml", "xbp.yml"] {
for base in [project_root.join(".xbp"), project_root.to_path_buf()] {
let candidate = base.join(name);
if !candidate.exists() {
continue;
}
let candidate_canon = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
if candidate_canon == primary_canon {
continue;
}
if fs::remove_file(&candidate).is_ok() && retired.is_none() {
retired = Some(candidate);
}
}
}
retired
}
async fn current_git_branch() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
}
async fn resolve_nested_service_candidate(
project_root: &Path,
current_dir: &Path,
) -> Result<Option<NestedServiceCandidate>, String> {
for candidate in ancestor_dirs_between(current_dir, project_root) {
if !contains_service_discovery_marker(&candidate) {
continue;
}
let project_type: ProjectType = ProjectDetector::detect_project_type(&candidate)
.await
.unwrap_or(ProjectType::Unknown);
if !matches!(project_type, ProjectType::Unknown)
|| !discover_service_version_targets_for_project(&candidate, project_root, &[])
.is_empty()
{
return Ok(Some(NestedServiceCandidate {
service_root: candidate,
project_type,
}));
}
}
Ok(None)
}
fn ancestor_dirs_between(current_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut cursor: Option<&Path> = Some(current_dir);
while let Some(dir) = cursor {
if dir == project_root {
break;
}
dirs.push(dir.to_path_buf());
cursor = dir.parent();
}
dirs
}
fn contains_service_discovery_marker(dir: &Path) -> bool {
SERVICE_DISCOVERY_MARKERS
.iter()
.any(|marker| dir.join(marker).exists())
}
fn ensure_root_service_entry(
config: &mut XbpConfig,
project_root: &Path,
claimed_targets: &[String],
) {
if config.services.is_some() {
return;
}
let mut root_service: ServiceConfig = legacy_service_from_config(config);
let claimed: HashSet<&str> = claimed_targets.iter().map(String::as_str).collect();
let remaining_targets: Vec<String> = collect_service_manifest_targets_from_config(config, project_root)
.into_iter()
.filter(|target| !claimed.contains(target.as_str()))
.collect::<Vec<_>>();
root_service.version_targets = if remaining_targets.is_empty() {
None
} else {
Some(remaining_targets)
};
config.services = Some(vec![root_service]);
}
fn collect_service_manifest_targets_from_config(
config: &XbpConfig,
project_root: &Path,
) -> Vec<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut manifests: Vec<String> = Vec::new();
for target in &config.version_targets {
let relative: String = collapse_project_path(project_root, target);
if is_service_version_manifest(&relative) && seen.insert(relative.clone()) {
manifests.push(relative);
}
}
if let Some(publish) = &config.publish {
for manifest_path in [publish.npm.as_ref(), publish.crates.as_ref()]
.into_iter()
.flatten()
.filter_map(|target| target.manifest_path.as_ref())
{
let relative: String = collapse_project_path(project_root, manifest_path);
if is_service_version_manifest(&relative) && seen.insert(relative.clone()) {
manifests.push(relative);
}
}
}
manifests
}
fn is_service_version_manifest(path: &str) -> bool {
let file_name: &str = Path::new(path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default();
SERVICE_VERSION_MANIFESTS.contains(&file_name)
}
fn upsert_service_config(
config: &mut XbpConfig,
service: ServiceConfig,
service_root_relative: &str,
version_targets: &[String],
) {
let services: &mut Vec<ServiceConfig> = config.services.get_or_insert_with(Vec::new);
let service_target_set: HashSet<&str> = version_targets.iter().map(String::as_str).collect();
let existing_index: Option<usize> = services.iter().position(|existing| {
existing.root_directory.as_deref() == Some(service_root_relative)
|| existing
.version_targets
.as_ref()
.map(|targets| {
targets
.iter()
.any(|target| service_target_set.contains(target.as_str()))
})
.unwrap_or(false)
|| existing.name.eq_ignore_ascii_case(&service.name)
});
if let Some(index) = existing_index {
let existing: ServiceConfig = services.remove(index);
services.insert(index, merge_service_config(existing, service));
} else {
services.push(service);
}
}
fn merge_service_config(existing: ServiceConfig, detected: ServiceConfig) -> ServiceConfig {
let target_freeze: Option<bool> = existing.target_freeze.or(detected.target_freeze);
let target: String = if existing.is_target_frozen() || target_freeze.unwrap_or(false) {
existing.target
} else {
detected.target
};
ServiceConfig {
name: detected.name,
target,
target_freeze,
branch: detected.branch,
port: detected.port,
root_directory: detected.root_directory,
environment: merge_environment_maps(existing.environment, detected.environment),
url: existing.url.or(detected.url),
healthcheck_path: existing.healthcheck_path.or(detected.healthcheck_path),
restart_policy: existing.restart_policy.or(detected.restart_policy),
restart_policy_max_failure_count: existing
.restart_policy_max_failure_count
.or(detected.restart_policy_max_failure_count),
start_wrapper: existing.start_wrapper.or(detected.start_wrapper),
commands: merge_service_commands(existing.commands, detected.commands),
force_run_from_root: existing
.force_run_from_root
.or(detected.force_run_from_root),
version_targets: detected.version_targets.or(existing.version_targets),
depends_on: existing.depends_on.or(detected.depends_on),
watch_paths: existing.watch_paths.or(detected.watch_paths),
versioning: existing.versioning.or(detected.versioning),
release: existing.release.or(detected.release),
systemd_service_name: existing
.systemd_service_name
.or(detected.systemd_service_name),
systemd: existing.systemd.or(detected.systemd),
openapi: existing.openapi.or(detected.openapi),
oci: existing.oci.or(detected.oci),
deploy: existing.deploy.or(detected.deploy),
discord: existing.discord.or(detected.discord),
file_associations: if existing.file_associations.is_empty() {
detected.file_associations
} else {
existing.file_associations
},
}
}
fn merge_environment_maps(
existing: Option<HashMap<String, String>>,
detected: Option<HashMap<String, String>>,
) -> Option<HashMap<String, String>> {
match (existing, detected) {
(None, None) => None,
(Some(existing), None) => Some(existing),
(None, Some(detected)) => Some(detected),
(Some(mut existing), Some(detected)) => {
for (key, value) in detected {
existing.insert(key, value);
}
Some(existing)
}
}
}
fn merge_service_commands(
existing: Option<ServiceCommands>,
detected: Option<ServiceCommands>,
) -> Option<ServiceCommands> {
match (existing, detected) {
(None, None) => None,
(Some(existing), None) => Some(existing),
(None, Some(detected)) => Some(detected),
(Some(mut existing), Some(detected)) => {
existing.pre = existing.pre.or(detected.pre);
existing.install = existing.install.or(detected.install);
existing.build = existing.build.or(detected.build);
existing.start = existing.start.or(detected.start);
existing.dev = existing.dev.or(detected.dev);
for (name, command) in detected.custom {
existing.custom.entry(name).or_insert(command);
}
Some(existing)
}
}
}
fn merge_project_version_targets(
config: &mut XbpConfig,
project_root: &Path,
version_targets: &[String],
) {
let ignore = load_project_xbp_ignore(project_root, &config.ignore_paths);
let mut seen: HashSet<String> = config.version_targets.iter().cloned().collect();
for target in version_targets {
if ignore.ignores_version_target(target) {
continue;
}
if seen.insert(target.clone()) {
config.version_targets.push(target.clone());
}
}
config
.version_targets
.retain(|target| !ignore.ignores_version_target(target));
config.version_targets.sort();
config.version_targets.dedup();
}
#[cfg(test)]
mod tests {
use super::{
ancestor_dirs_between, app_type_ids, apply_deploy_targets_to_service_deploy,
contains_service_discovery_marker, deploy_target_ids, ensure_root_service_entry,
infer_app_type_from_package_json, infer_deploy_targets, merge_project_version_targets,
resolve_deploy_provider, retire_non_primary_project_configs,
};
use crate::commands::project_services::discover_service_version_targets_for_project;
use crate::strategies::deployment_config::ServiceDeployConfig;
use crate::strategies::project_detector::ProjectType;
use crate::strategies::{ServiceConfig, XbpConfig};
use std::fs;
use std::path::PathBuf;
fn temp_dir(name: &str) -> PathBuf {
let dir: PathBuf = std::env::temp_dir().join(format!("xbp-init-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn greenfield_init_path_is_xbp_toml() {
let root = temp_dir("greenfield-toml");
let path = crate::utils::default_project_config_path(
&root,
crate::utils::DEFAULT_PROJECT_CONFIG_KIND,
);
assert!(
path.ends_with(".xbp/xbp.toml") || path.ends_with(r".xbp\xbp.toml"),
"expected .xbp/xbp.toml, got {}",
path.display()
);
assert_eq!(crate::utils::DEFAULT_PROJECT_CONFIG_KIND, "toml");
let _ = fs::remove_dir_all(root);
}
#[test]
fn retire_yaml_when_migrating_to_toml() {
let root = temp_dir("retire-yaml");
let dot = root.join(".xbp");
fs::create_dir_all(&dot).unwrap();
let yaml = dot.join("xbp.yaml");
let toml = dot.join("xbp.toml");
fs::write(&yaml, "project_name: demo\n").unwrap();
fs::write(&toml, "project_name = \"demo\"\n").unwrap();
let retired = retire_non_primary_project_configs(&root, &toml);
assert!(retired.is_some());
assert!(!yaml.exists());
assert!(toml.exists());
let _ = fs::remove_dir_all(root);
}
fn base_config() -> XbpConfig {
XbpConfig {
project_name: "demo".to_string(),
version: "0.1.0".to_string(),
port: 3000,
build_dir: ".".to_string(),
app_type: Some("rust".to_string()),
build_command: Some("cargo build --release".to_string()),
start_command: Some("./target/release/demo".to_string()),
install_command: None,
environment: None,
services: None,
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: None,
target: Some("rust".to_string()),
branch: Some("main".to_string()),
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: vec![
"crates/cli/Cargo.toml".to_string(),
"apps/web/package.json".to_string(),
],
version_domains: Vec::new(),
ignore_paths: Vec::new(),
watch_ignore_paths: Vec::new(),
versioning_disabled: Vec::new(),
release_disabled: Vec::new(),
versioning: None,
discord: None,
audit: None,
}
}
#[test]
fn ancestor_dir_scan_stops_before_project_root() {
let root: PathBuf = PathBuf::from("C:/repo");
let nested: PathBuf = PathBuf::from("C:/repo/apps/web/src");
let dirs: Vec<PathBuf> = ancestor_dirs_between(&nested, &root);
assert_eq!(
dirs,
vec![
PathBuf::from("C:/repo/apps/web/src"),
PathBuf::from("C:/repo/apps/web"),
PathBuf::from("C:/repo/apps"),
]
);
}
#[test]
fn discovery_markers_and_version_targets_detect_nested_package() {
let project_root: PathBuf = temp_dir("markers");
let service_root: PathBuf = project_root.join("apps").join("web");
fs::create_dir_all(&service_root).expect("create service root");
fs::write(service_root.join("package.json"), "{ \"name\": \"web\" }")
.expect("write package");
assert!(contains_service_discovery_marker(&service_root));
assert_eq!(
discover_service_version_targets_for_project(&service_root, &project_root, &[]),
vec!["apps/web/package.json".to_string()]
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn ensuring_root_service_claims_remaining_targets() {
let project_root: PathBuf = temp_dir("root-service");
let mut config: XbpConfig = base_config();
ensure_root_service_entry(
&mut config,
&project_root,
&["apps/web/package.json".to_string()],
);
let services: Vec<ServiceConfig> = config.services.expect("services");
assert_eq!(services.len(), 1);
assert_eq!(services[0].name, "demo");
assert_eq!(
services[0].version_targets,
Some(vec!["crates/cli/Cargo.toml".to_string()])
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn project_version_targets_merge_without_duplicates() {
let mut config: XbpConfig = base_config();
let project_root: PathBuf = temp_dir("merge-targets");
merge_project_version_targets(
&mut config,
&project_root,
&[
"apps/web/package.json".to_string(),
"apps/api/pyproject.toml".to_string(),
],
);
assert_eq!(
config.version_targets,
vec![
"apps/api/pyproject.toml".to_string(),
"apps/web/package.json".to_string(),
"crates/cli/Cargo.toml".to_string(),
]
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn merge_root_service_tests_reference_service_config_type() {
let _: Option<ServiceConfig> = None;
}
#[test]
fn app_type_catalog_includes_requested_frameworks_not_platforms() {
let ids = app_type_ids();
for expected in [
"nextjs",
"tanstack-start",
"remix",
"astro",
"hono",
"deno",
"electron",
"react-native",
"tauri",
"swiftui",
"rust-axum",
"rust-actix",
"rust",
"go",
] {
assert!(
ids.contains(&expected),
"missing app type {expected} in {ids:?}"
);
}
for platform in ["vercel", "railway", "worker", "kubernetes"] {
assert!(
!ids.contains(&platform),
"{platform} should be a deploy target, not an app type"
);
}
}
#[test]
fn deploy_target_catalog_covers_platforms() {
let ids = deploy_target_ids();
for expected in [
"kubernetes",
"worker",
"cloudflare-containers",
"vercel",
"railway",
"local",
"docker",
] {
assert!(
ids.contains(&expected),
"missing deploy target {expected} in {ids:?}"
);
}
assert_eq!(resolve_deploy_provider("vercel"), Some("vercel"));
assert_eq!(resolve_deploy_provider("worker"), Some("worker"));
assert_eq!(
resolve_deploy_provider("cloudflare-containers"),
Some("cloudflare-containers")
);
}
#[test]
fn package_json_detects_tanstack_and_hono() {
let root = temp_dir("pkg-detect");
fs::write(
root.join("package.json"),
r#"{ "dependencies": { "@tanstack/react-start": "1.0.0", "hono": "4.0.0" } }"#,
)
.unwrap();
assert_eq!(
infer_app_type_from_package_json(&root).as_deref(),
Some("tanstack-start")
);
fs::write(
root.join("package.json"),
r#"{ "dependencies": { "hono": "4.0.0" } }"#,
)
.unwrap();
assert_eq!(
infer_app_type_from_package_json(&root).as_deref(),
Some("hono")
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn infer_deploy_targets_from_platform_markers() {
let root = temp_dir("deploy-markers");
fs::write(root.join("vercel.json"), "{}").unwrap();
fs::write(root.join("wrangler.toml"), "name = \"demo\"\n").unwrap();
let targets = infer_deploy_targets(&root, &ProjectType::Unknown);
assert!(targets.contains(&"vercel".to_string()));
assert!(targets.contains(&"worker".to_string()));
let _ = fs::remove_dir_all(root);
}
#[test]
fn apply_deploy_targets_writes_destinations() {
let mut deploy = ServiceDeployConfig::default();
apply_deploy_targets_to_service_deploy(
&mut deploy,
&["kubernetes".into(), "vercel".into()],
);
assert_eq!(deploy.provider.as_deref(), Some("kubernetes"));
assert!(deploy.destinations.contains_key("kubernetes"));
assert!(deploy.destinations.contains_key("vercel"));
assert_eq!(
deploy.destinations["vercel"].provider.as_deref(),
Some("vercel")
);
}
}