1use super::deployment_config::DeploymentConfig;
7use super::project_detector::{ProjectDetector, ProjectType};
8use std::time::Instant;
9use tokio::process::Command;
10use tracing::{debug, info};
11
12pub struct DeploymentExecutor {
13 config: DeploymentConfig,
14 debug: bool,
15 detected_project_type: Option<ProjectType>,
16}
17
18impl DeploymentExecutor {
19 pub fn new(config: DeploymentConfig, debug: bool) -> Self {
20 Self {
21 config,
22 debug,
23 detected_project_type: None,
24 }
25 }
26
27 pub async fn deploy(&mut self) -> Result<(), String> {
29 let start_time: Instant = Instant::now();
30
31 self.validate_project_directory().await?;
32 self.detect_and_configure_project().await?;
33 if self.is_provider_managed_deploy() {
34 self.deploy_provider_managed_project().await?;
35 self.config.save_xbp_config(None).await?;
36
37 let elapsed: std::time::Duration = start_time.elapsed();
38 info!(
39 "Successfully deployed {} via provider CLI in {:.2?}",
40 self.config.app_name, elapsed
41 );
42 return Ok(());
43 }
44
45 self.ensure_port_available().await?;
46 self.git_reset_and_pull().await?;
47 if let Some(install_cmd) = &self.config.install_command.clone() {
48 self.run_install_command(install_cmd).await?;
49 }
50 if let Some(build_cmd) = &self.config.build_command.clone() {
51 self.run_build_command(build_cmd).await?;
52 }
53 self.stop_existing_processes().await?;
54 self.start_application().await?;
55 self.save_pm2_config().await?;
56 self.config.save_xbp_config(None).await?;
57
58 let elapsed: std::time::Duration = start_time.elapsed();
59 info!(
60 "Successfully deployed {} on port {} in {:.2?}",
61 self.config.app_name, self.config.port, elapsed
62 );
63
64 Ok(())
65 }
66
67 async fn detect_and_configure_project(&mut self) -> Result<(), String> {
69 if self.debug {
70 debug!(
71 "Detecting project type in: {}",
72 self.config.app_dir.display()
73 );
74 }
75
76 let project_type = ProjectDetector::detect_project_type(&self.config.app_dir).await?;
77 self.detected_project_type = Some(project_type.clone());
78
79 match &project_type {
80 ProjectType::Docker { .. } => {
81 info!(" Detected Docker project (Dockerfile found)");
82 }
83 ProjectType::DockerCompose {
84 compose_files,
85 detected_ports,
86 } => {
87 info!(
88 " Detected Docker Compose project: {}",
89 compose_files.join(", ")
90 );
91 if !detected_ports.is_empty() {
92 info!(" Detected ports: {:?}", detected_ports);
93 }
94 }
95 ProjectType::Railway {
96 has_railway_json,
97 has_railway_toml,
98 } => {
99 let mut parts = Vec::new();
100 if *has_railway_json {
101 parts.push("railway.json");
102 }
103 if *has_railway_toml {
104 parts.push("railway.toml");
105 }
106 info!(" Detected Railway manifest: {}", parts.join(", "));
107 }
108 ProjectType::Vercel {
109 has_vercel_json,
110 has_project_link,
111 } => {
112 let mut parts = Vec::new();
113 if *has_vercel_json {
114 parts.push("vercel.json");
115 }
116 if *has_project_link {
117 parts.push(".vercel/project.json");
118 }
119 info!(" Detected Vercel manifest: {}", parts.join(", "));
120 }
121 ProjectType::Python {
122 has_requirements_txt,
123 has_pyproject_toml,
124 } => {
125 let mut parts = Vec::new();
126 if *has_requirements_txt {
127 parts.push("requirements.txt");
128 }
129 if *has_pyproject_toml {
130 parts.push("pyproject.toml");
131 }
132 info!(" Detected Python project: {}", parts.join(", "));
133 }
134 ProjectType::OpenApi { spec_files } => {
135 info!(" Detected OpenAPI spec: {}", spec_files.join(", "));
136 }
137 ProjectType::Terraform { tf_file_count } => {
138 info!(" Detected Terraform files: {}", tf_file_count);
139 }
140 ProjectType::NextJs {
141 package_json,
142 has_next_config,
143 } => {
144 info!(
145 " Detected Next.js project: {} v{}",
146 package_json.name, package_json.version
147 );
148 if *has_next_config {
149 info!(" Next.js configuration found");
150 }
151 }
152 ProjectType::NodeJs { package_json } => {
153 info!(
154 " Detected Node.js project: {} v{}",
155 package_json.name, package_json.version
156 );
157 }
158 ProjectType::Rust { cargo_toml } => {
159 info!(
160 " Detected Rust project: {} v{}",
161 cargo_toml.name, cargo_toml.version
162 );
163 }
164 ProjectType::Unknown => {
165 info!(" Unknown project type, using default configuration");
166 }
167 }
168
169 let recommendations =
170 ProjectDetector::get_deployment_recommendations(&self.config.app_dir, &project_type);
171 self.config.merge_with_recommendations(&recommendations);
172
173 if self.debug {
174 debug!("Build command: {:?}", self.config.build_command);
175 debug!("Start command: {:?}", self.config.start_command);
176 debug!("Install command: {:?}", self.config.install_command);
177 }
178
179 Ok(())
180 }
181
182 fn is_provider_managed_deploy(&self) -> bool {
183 matches!(
184 self.detected_project_type,
185 Some(ProjectType::Railway { .. }) | Some(ProjectType::Vercel { .. })
186 )
187 }
188
189 async fn deploy_provider_managed_project(&self) -> Result<(), String> {
190 if let Some(install_cmd) = &self.config.install_command {
191 self.run_install_command(install_cmd).await?;
192 }
193 if let Some(build_cmd) = &self.config.build_command {
194 self.run_build_command(build_cmd).await?;
195 }
196
197 let (program, args, install_target, label) = match self.detected_project_type.as_ref() {
198 Some(ProjectType::Railway { .. }) => (
199 "railway",
200 vec!["up".to_string()],
201 "railway",
202 "Railway deploy",
203 ),
204 Some(ProjectType::Vercel { .. }) => (
205 "vercel",
206 Vec::new(),
207 "vercel",
208 "Vercel deploy (default CLI command)",
209 ),
210 _ => {
211 return Err(
212 "Provider-managed deployment requested without provider project type"
213 .to_string(),
214 )
215 }
216 };
217
218 if !crate::utils::command_exists(program) {
219 return Err(format!(
220 "{} requires `{}` on PATH. Install it with `xbp install {}` first.",
221 label, program, install_target
222 ));
223 }
224
225 let command_display = if args.is_empty() {
226 program.to_string()
227 } else {
228 format!("{} {}", program, args.join(" "))
229 };
230 info!(" Running {}: {}", label, command_display);
231
232 let output = Command::new(program)
233 .args(&args)
234 .envs(&self.config.environment)
235 .current_dir(&self.config.app_dir)
236 .output()
237 .await
238 .map_err(|e| format!("Failed to execute {}: {}", label, e))?;
239
240 if !output.status.success() {
241 return Err(format!(
242 "{} failed: {}",
243 label,
244 String::from_utf8_lossy(&output.stderr)
245 ));
246 }
247
248 let stdout = String::from_utf8_lossy(&output.stdout);
249 if !stdout.trim().is_empty() {
250 info!("{}", stdout.trim());
251 }
252
253 Ok(())
254 }
255
256 async fn ensure_port_available(&mut self) -> Result<(), String> {
258 info!(" Checking port {} availability...", self.config.port);
259
260 let port_check = Command::new("sh")
261 .arg("-c")
262 .arg(format!("sudo fuser {}/tcp", self.config.port))
263 .output()
264 .await;
265
266 match port_check {
267 Ok(output) if output.status.success() => {
268 info!(
269 " Port {} is in use, attempting to kill process...",
270 self.config.port
271 );
272
273 let kill_output = Command::new("sh")
274 .arg("-c")
275 .arg(format!("sudo fuser -k {}/tcp", self.config.port))
276 .output()
277 .await
278 .map_err(|e| {
279 format!("Failed to kill process on port {}: {}", self.config.port, e)
280 })?;
281
282 if kill_output.status.success() {
283 info!(" Successfully killed process on port {}", self.config.port);
284 } else {
285 info!(" Failed to kill process, searching for available port...");
286 let new_port = self.find_available_port().await?;
287 self.config.update_port(new_port);
288 info!(" Found available port: {}", new_port);
289 }
290 }
291 _ => {
292 info!(" Port {} is available", self.config.port);
293 }
294 }
295
296 Ok(())
297 }
298
299 async fn find_available_port(&self) -> Result<u16, String> {
301 for port in 1025..=65535 {
302 let check_output = Command::new("sh")
303 .arg("-c")
304 .arg(format!("sudo fuser {}/tcp", port))
305 .output()
306 .await;
307
308 if let Ok(output) = check_output {
309 if !output.status.success() {
310 return Ok(port);
311 }
312 }
313 }
314
315 Err("No available ports found in range 1025-65535".to_string())
316 }
317
318 async fn validate_project_directory(&self) -> Result<(), String> {
320 if !self.config.app_dir.exists() {
321 return Err(format!(
322 "Project directory does not exist: {}",
323 self.config.app_dir.display()
324 ));
325 }
326
327 if !self.config.app_dir.is_dir() {
328 return Err(format!(
329 "Project path is not a directory: {}",
330 self.config.app_dir.display()
331 ));
332 }
333
334 info!(" Project directory: {}", self.config.app_dir.display());
335 Ok(())
336 }
337
338 async fn git_reset_and_pull(&self) -> Result<(), String> {
340 info!(" Resetting git repository...");
341
342 let reset_output = Command::new("git")
343 .arg("reset")
344 .arg("--hard")
345 .current_dir(&self.config.app_dir)
346 .output()
347 .await
348 .map_err(|e| format!("Failed to execute git reset: {}", e))?;
349
350 if !reset_output.status.success() {
351 return Err(format!(
352 "Git reset failed: {}",
353 String::from_utf8_lossy(&reset_output.stderr)
354 ));
355 }
356
357 info!(" Pulling latest changes...");
358
359 let pull_output = Command::new("git")
360 .arg("pull")
361 .arg("origin")
362 .arg("main")
363 .current_dir(&self.config.app_dir)
364 .output()
365 .await
366 .map_err(|e| format!("Failed to execute git pull: {}", e))?;
367
368 if !pull_output.status.success() {
369 return Err(format!(
370 "Git pull failed: {}",
371 String::from_utf8_lossy(&pull_output.stderr)
372 ));
373 }
374
375 info!(" Git operations completed");
376 Ok(())
377 }
378
379 async fn run_install_command(&self, install_cmd: &str) -> Result<(), String> {
381 info!(" Installing dependencies: {}", install_cmd);
382
383 let install_output = Command::new("sh")
384 .arg("-c")
385 .arg(install_cmd)
386 .envs(&self.config.environment)
387 .current_dir(&self.config.app_dir)
388 .output()
389 .await
390 .map_err(|e| format!("Failed to execute install command: {}", e))?;
391
392 if !install_output.status.success() {
393 return Err(format!(
394 "Install command failed: {}",
395 String::from_utf8_lossy(&install_output.stderr)
396 ));
397 }
398
399 info!(" Dependencies installed successfully");
400 Ok(())
401 }
402
403 async fn run_build_command(&self, build_cmd: &str) -> Result<(), String> {
405 info!(" Building project: {}", build_cmd);
406
407 let build_output = Command::new("sh")
408 .arg("-c")
409 .arg(build_cmd)
410 .envs(&self.config.environment)
411 .current_dir(&self.config.app_dir)
412 .output()
413 .await
414 .map_err(|e| format!("Failed to execute build command: {}", e))?;
415
416 if !build_output.status.success() {
417 return Err(format!(
418 "Build command failed: {}",
419 String::from_utf8_lossy(&build_output.stderr)
420 ));
421 }
422
423 info!("Project built successfully");
424 Ok(())
425 }
426
427 async fn stop_existing_processes(&self) -> Result<(), String> {
429 info!("Stopping existing processes...");
430
431 let pm2_stop = Command::new("pm2")
432 .arg("stop")
433 .arg(&self.config.app_name)
434 .output()
435 .await;
436
437 match pm2_stop {
438 Ok(output) if output.status.success() => {
439 info!(" Stopped PM2 process: {}", self.config.app_name);
440 }
441 _ => {
442 info!(" No existing PM2 process found");
443 }
444 }
445
446 let kill_port = Command::new("sh")
447 .arg("-c")
448 .arg(format!("sudo fuser -k {}/tcp", self.config.port))
449 .output()
450 .await;
451
452 match kill_port {
453 Ok(output) if output.status.success() => {
454 info!(" Killed processes on port {}", self.config.port);
455 }
456 _ => {
457 info!(" No processes found on port {}", self.config.port);
458 }
459 }
460
461 Ok(())
462 }
463
464 async fn start_application(&self) -> Result<(), String> {
466 let start_cmd = self
467 .config
468 .start_command
469 .as_ref()
470 .ok_or("No start command configured")?;
471
472 info!(" Starting application: {}", start_cmd);
473
474 let pm2_cmd = format!("{} --port {}", start_cmd, self.config.port);
476
477 let start_output = Command::new("pm2")
478 .arg("start")
479 .arg(&pm2_cmd)
480 .arg("--name")
481 .arg(&self.config.app_name)
482 .arg("--")
483 .arg("--port")
484 .arg(self.config.port.to_string())
485 .envs(&self.config.environment)
486 .current_dir(&self.config.app_dir)
487 .output()
488 .await
489 .map_err(|e| format!("Failed to start PM2 process: {}", e))?;
490
491 if !start_output.status.success() {
492 return Err(format!(
493 "Failed to start application: {}",
494 String::from_utf8_lossy(&start_output.stderr)
495 ));
496 }
497
498 info!("Application started successfully");
499 Ok(())
500 }
501
502 async fn save_pm2_config(&self) -> Result<(), String> {
504 info!("Saving PM2 configuration...");
505
506 let save_output = Command::new("pm2")
507 .arg("save")
508 .output()
509 .await
510 .map_err(|e| format!("Failed to save PM2 config: {}", e))?;
511
512 if !save_output.status.success() {
513 return Err(format!(
514 "PM2 save failed: {}",
515 String::from_utf8_lossy(&save_output.stderr)
516 ));
517 }
518
519 info!("PM2 configuration saved");
520 Ok(())
521 }
522}