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