use std::{path::PathBuf, time::Duration}; use anyhow::{Result, bail}; use futures::StreamExt; use indicatif::{ProgressBar, ProgressFinish, ProgressStyle}; use is_terminal::IsTerminal; use crate::{ consts::TICK_STRING, controllers::{ deployment::{stream_build_logs, stream_deploy_logs}, environment::get_matched_environment, project::get_project, service::get_or_prompt_service, upload::{create_deploy_tarball, upload_deploy_tarball}, }, subscription::subscribe_graphql, subscriptions::deployment::DeploymentStatus, util::logs::{LogFormat, print_log}, }; use super::*; /// Upload and deploy project from the current directory #[derive(Parser)] pub struct Args { path: Option, #[clap(short, long)] /// Don't attach to the log stream detach: bool, #[clap(short, long)] /// Stream build logs only, then exit (equivalent to setting $CI=true). ci: bool, #[clap(short, long)] /// Service to deploy to (defaults to linked service) service: Option, #[clap(short, long)] /// Environment to deploy to (defaults to linked environment) environment: Option, #[clap(short = 'p', long, value_name = "PROJECT_ID")] /// Project ID to deploy to (defaults to linked project) project: Option, #[clap(long)] /// Don't ignore paths from .gitignore no_gitignore: bool, #[clap(long)] /// Use the path argument as the prefix for the archive instead of the project directory. path_as_root: bool, #[clap(long)] /// Verbose output verbose: bool, #[clap(long)] /// Output logs in JSON format (implies CI mode behavior) json: bool, #[clap(short, long)] /// Message to attach to the deployment message: Option, } pub async fn command(args: Args) -> Result<()> { let configs = Configs::new()?; let hostname = configs.get_host(); let client = GQLClient::new_authorized(&configs)?; if args.project.is_some() && args.environment.is_none() { bail!("--environment is required when using --project"); } let linked_project = if args.project.is_none() { Some(configs.get_linked_project().await?) } else { None }; let linked_project_path = linked_project.as_ref().map(|lp| lp.project_path.clone()); let deploy_paths = get_deploy_paths(&args, linked_project_path)?; let project_id = args .project .clone() .or_else(|| linked_project.as_ref().map(|lp| lp.project.clone())) .ok_or_else(|| { anyhow::anyhow!("No project specified. Use --project or run `railway link` first") })?; let project = get_project(&client, &configs, project_id.clone()).await?; let environment = args .environment .clone() .or_else(|| { linked_project .as_ref() .and_then(|lp| lp.environment.clone()) }) .ok_or_else(|| { anyhow::anyhow!( "No environment specified. Set RAILWAY_ENVIRONMENT_ID, use --environment, or run `railway environment` to link one." ) })?; let environment_id = get_matched_environment(&project, environment)?.id; let service = get_or_prompt_service(linked_project, project, args.service).await?; let is_tty = std::io::stdout().is_terminal() && !args.json; let spinner = if is_tty { let spinner = ProgressBar::new_spinner() .with_style( ProgressStyle::default_spinner() .tick_chars(TICK_STRING) .template("{spinner:.green} {msg:.cyan.bold}")?, ) .with_message("Indexing"); spinner.enable_steady_tick(Duration::from_millis(100)); Some(spinner) } else if !args.json { println!("Indexing..."); None } else { None }; let mut progress_bar: Option = None; let body = create_deploy_tarball( &deploy_paths.project_path, &deploy_paths.archive_prefix_path, args.no_gitignore, |current, total| { if current == 0 { // Indexing complete if let Some(s) = &spinner { s.finish_with_message("Indexed"); } if is_tty { let pg = ProgressBar::new(total as u64) .with_style( ProgressStyle::default_bar() .template( "{spinner:.green} {msg:.cyan.bold} [{bar:20}] {percent}% ", ) .unwrap() .progress_chars("=> ") .tick_chars(TICK_STRING), ) .with_message("Compressing") .with_finish(ProgressFinish::WithMessage("Compressed".into())); pg.enable_steady_tick(Duration::from_millis(100)); progress_bar = Some(pg); } } else if let Some(pg) = &progress_bar { pg.inc(1); } }, )?; // Ensure progress bar finishes if no entries were processed drop(progress_bar); if args.verbose { println!("railway up"); println!("service: {}", service.as_deref().unwrap_or_default()); println!("environment: {environment_id}"); println!("bytes: {}", body.len()); } let spinner = if std::io::stdout().is_terminal() && !args.json { let spinner = ProgressBar::new_spinner() .with_style( ProgressStyle::default_spinner() .tick_chars(TICK_STRING) .template("{spinner:.green} {msg:.cyan.bold}")?, ) .with_message("Uploading"); spinner.enable_steady_tick(Duration::from_millis(100)); Some(spinner) } else if !args.json { println!("Uploading..."); None } else { None }; let up_result = upload_deploy_tarball( &client, hostname, &project_id, &environment_id, service.as_deref(), args.message.as_deref(), body, ) .await; let body = match up_result { Err(e) => { if let Some(spinner) = spinner { spinner.finish_with_message("Failed"); } return Err(e); } Ok(body) => { if let Some(spinner) = spinner { spinner.finish_with_message("Uploaded"); } body } }; let deployment_id = body.deployment_id; if !args.json { println!(" {}: {}", "Build Logs".green().bold(), body.logs_url); } if args.detach { if args.json { println!( "{}", serde_json::json!({"deploymentId": deployment_id, "logsUrl": body.logs_url}) ); } return Ok(()); } let ci_mode = Configs::env_is_ci() || args.ci || args.json; if ci_mode && !args.json { println!("{}", "CI mode enabled".green().bold()); } // If the user is not in a terminal AND if we are not in CI mode, don't stream logs if !std::io::stdout().is_terminal() && !ci_mode { return Ok(()); } // Create vector of log streaming tasks // Always stream build logs // Add a small delay before starting log streaming to allow the backend // to fully register the deployment. This prevents race conditions where // the WebSocket subscription fails because the deployment isn't ready yet. tokio::time::sleep(Duration::from_millis(500)).await; let build_deployment_id = deployment_id.clone(); let json_mode = args.json; let ci_flag = args.ci; let mut tasks = vec![tokio::task::spawn(async move { if let Err(e) = stream_build_logs(build_deployment_id, None, |log| { let should_exit = ci_flag && log.message.starts_with("No changed files matched patterns"); if json_mode { print_log(log, true, LogFormat::LevelOnly); } else { println!("{}", log.message); } if should_exit { std::process::exit(0); } }) .await { eprintln!("Failed to stream build logs: {e}"); if ci_mode { std::process::exit(1); } } })]; // Stream deploy logs only if is not in ci mode if !ci_mode { let deploy_deployment_id = deployment_id.clone(); tasks.push(tokio::task::spawn(async move { if let Err(e) = stream_deploy_logs(deploy_deployment_id, None, |log| { print_log(log, false, LogFormat::Full) }) .await { eprintln!("Failed to stream deploy logs: {e}"); } })); } let mut stream = subscribe_graphql::(subscriptions::deployment::Variables { id: deployment_id.clone(), }) .await?; tokio::task::spawn(async move { while let Some(Ok(res)) = stream.next().await { if let Some(errors) = res.errors { if json_mode { eprintln!( "{}", serde_json::json!({"error": errors.iter().map(|e| e.to_string()).collect::>().join("; ")}) ); } else { eprintln!( "Failed to get deploy status: {}", errors .iter() .map(|err| err.to_string()) .collect::>() .join("; ") ); } if ci_mode { std::process::exit(1); } } if let Some(data) = res.data { match data.deployment.status { DeploymentStatus::SUCCESS => { if json_mode { println!("{}", serde_json::json!({"status": "success"})); } else { println!("{}", "Deploy complete".green().bold()); } if ci_mode { std::process::exit(0); } } DeploymentStatus::FAILED => { if json_mode { println!("{}", serde_json::json!({"status": "failed"})); } else { println!("{}", "Deploy failed".red().bold()); } std::process::exit(1); } DeploymentStatus::CRASHED => { if json_mode { println!("{}", serde_json::json!({"status": "crashed"})); } else { println!("{}", "Deploy crashed".red().bold()); } std::process::exit(1); } _ => {} } } } }); futures::future::join_all(tasks).await; Ok(()) } struct DeployPaths { project_path: PathBuf, archive_prefix_path: PathBuf, } fn get_deploy_paths(args: &Args, linked_project_path: Option) -> Result { if args.path_as_root { if args.path.is_none() { bail!("--path-as-root requires a path to be specified"); } let path = args.path.clone().unwrap(); Ok(DeployPaths { project_path: path.clone(), archive_prefix_path: path, }) } else { let project_dir: PathBuf = match linked_project_path { Some(path) => PathBuf::from(path), None => std::env::current_dir().context("Failed to get current directory")?, }; let project_path = match args.path { Some(ref path) => path.clone(), None => project_dir.clone(), }; Ok(DeployPaths { project_path, archive_prefix_path: project_dir, }) } }