use crate::cli::auto_commit::{
commit_paths, print_push_summary, print_skip, push_current_branch, AutoCommitRequest,
AutoCommitResult,
};
use crate::strategies::deployment_config::XbpConfig;
use crate::strategies::project_detector::{
infer_project_name as shared_infer_project_name, DeploymentRecommendations, PackageJsonInfo,
ProjectDetector, ProjectType,
};
use crate::utils::{
collapse_project_path, find_xbp_config_upwards, parse_env_file, to_env_references,
};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use dialoguer::{Confirm, Input, Select};
use regex::Regex;
use tokio::process::Command;
use tracing::debug;
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))?;
if let Some(found) = find_xbp_config_upwards(¤t_dir) {
let proceed = Confirm::new()
.with_prompt(format!(
"An XBP config already exists at {}. Overwrite?",
found.location
))
.default(false)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
if !proceed {
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 =
ProjectDetector::get_deployment_recommendations(¤t_dir, &project_type);
let inferred_name = infer_project_name(&project_type, ¤t_dir, &recommendations);
let app_type_guess = infer_app_type(&project_type);
let port_guess = detect_port(¤t_dir, &project_type, &recommendations);
let env_vars = detect_environment_from_env_files(¤t_dir);
let project_name: String = Input::new()
.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.clone()).map_err(|e| format!("Prompt failed: {}", e))?;
let port: u16 = Input::new()
.with_prompt("Primary port")
.default(port_guess)
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))?;
let build_dir = collapse_project_path(¤t_dir, ¤t_dir.to_string_lossy());
let config = 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,
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,
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,
};
let dot_xbp = current_dir.join(".xbp");
fs::create_dir_all(&dot_xbp)
.map_err(|e| format!("Failed to create {}: {}", dot_xbp.display(), e))?;
let yaml_path = dot_xbp.join("xbp.yaml");
let written_paths = write_configs(&config, &yaml_path)?;
let legacy_json_path = dot_xbp.join("xbp.json");
if legacy_json_path.exists() {
println!(
"Created {} (synced legacy {})",
yaml_path.display(),
legacy_json_path.display()
);
} else {
println!("Created {}", yaml_path.display());
}
match commit_paths(AutoCommitRequest {
project_root: ¤t_dir,
paths: written_paths,
message: "chore(xbp): initialize project config".to_string(),
action_label: "xbp init",
})
.await
{
Ok(AutoCommitResult::Committed(_)) => match push_current_branch(¤t_dir).await {
Ok(Some(outcome)) => print_push_summary(&outcome),
Ok(None) => {}
Err(e) => print_skip("xbp init", &format!("git push failed: {}", e)),
},
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)
}
fn infer_app_type(project_type: &ProjectType) -> Option<String> {
match project_type {
ProjectType::NextJs { .. } => Some("nextjs".to_string()),
ProjectType::NodeJs { package_json } => {
if has_express_dependency(package_json) {
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 { .. } => Some("railway".to_string()),
ProjectType::Python { .. } => Some("python".to_string()),
_ => None,
}
}
fn has_express_dependency(package_json: &PackageJsonInfo) -> bool {
package_json
.dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case("express"))
|| package_json
.dev_dependencies
.keys()
.any(|k| k.eq_ignore_ascii_case("express"))
}
fn select_app_type(detected: Option<String>) -> Result<String, String> {
let mut options: Vec<String> = vec![
"nextjs".to_string(),
"expressjs".to_string(),
"rust".to_string(),
"nodejs".to_string(),
"python".to_string(),
"docker".to_string(),
"custom...".to_string(),
];
let default_index = if let Some(ref guess) = detected {
if let Some(pos) = options.iter().position(|o| o == guess) {
pos
} else {
options.insert(0, format!("{} (detected)", guess));
0
}
} else {
0
};
let selection = Select::new()
.with_prompt("App type")
.items(&options)
.default(default_index)
.interact()
.map_err(|e| format!("Prompt failed: {}", e))?;
let choice = options
.get(selection)
.cloned()
.unwrap_or_else(|| "nextjs".to_string());
if choice == "custom..." {
Input::<String>::new()
.with_prompt("Enter app type")
.interact_text()
.map_err(|e| format!("Prompt failed: {}", e))
} else if let Some(stripped) = choice.strip_suffix(" (detected)") {
Ok(stripped.to_string())
} else {
Ok(choice)
}
}
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, yaml_path: &Path) -> Result<Vec<PathBuf>, String> {
let yaml = serde_yaml::to_string(config)
.map_err(|e| format!("Failed to serialize YAML config: {}", e))?;
fs::write(yaml_path, yaml)
.map_err(|e| format!("Failed to write {}: {}", yaml_path.display(), e))?;
let mut written_paths = vec![yaml_path.to_path_buf()];
let json_path = yaml_path
.parent()
.map(|parent| parent.join("xbp.json"))
.ok_or_else(|| "Invalid YAML config path".to_string())?;
if json_path.exists() {
let json = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize JSON config: {}", e))?;
fs::write(&json_path, json)
.map_err(|e| format!("Failed to write {}: {}", json_path.display(), e))?;
written_paths.push(json_path);
}
Ok(written_paths)
}
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())
}