diff --git a/.gitignore b/.gitignore index b77ed0b..d1aed90 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ bin/railway .direnv/* .env .DS_Store +test diff --git a/Cargo.lock b/Cargo.lock index f54cfa9..27fa7b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,6 +93,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", +] + [[package]] name = "async-tungstenite" version = "0.28.2" @@ -511,7 +522,7 @@ version = "3.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" dependencies = [ - "nix", + "nix 0.29.0", "windows-sys 0.59.0", ] @@ -803,6 +814,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -960,6 +981,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "globset" version = "0.4.15" @@ -1704,7 +1737,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -1719,6 +1752,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify" version = "8.0.0" @@ -1932,7 +1977,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" dependencies = [ "bytes 1.9.0", - "getrandom", + "getrandom 0.2.15", "rand", "ring", "rustc-hash", @@ -1968,11 +2013,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "railwayapp" version = "4.12.0" dependencies = [ "anyhow", + "async-trait", "async-tungstenite", "base64", "box_drawing", @@ -1988,6 +2040,7 @@ dependencies = [ "ctrlc", "derive-new", "dirs", + "fs2", "futures 0.3.31", "futures-util", "graphql-ws-client", @@ -2004,6 +2057,7 @@ dependencies = [ "inquire", "is-terminal", "json_dotpath", + "nix 0.30.1", "notify", "num_cpus", "open", @@ -2014,12 +2068,14 @@ dependencies = [ "serde", "serde_json", "serde_with", + "serde_yaml", "similar", "struct-field-names-as-array", "structstruck", "strum 0.26.3", "synchronized-writer", "tar", + "tempfile", "textwrap", "thiserror 2.0.9", "tokio", @@ -2056,7 +2112,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -2074,7 +2130,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.15", "libredox", "thiserror 1.0.69", ] @@ -2159,7 +2215,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "untrusted", "windows-sys 0.52.0", @@ -2385,6 +2441,19 @@ dependencies = [ "syn 2.0.93", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.7.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2641,6 +2710,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "textwrap" version = "0.16.1" @@ -2934,6 +3016,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3016,6 +3104,15 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.99" @@ -3447,6 +3544,12 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 9ea74ed..c709f9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ colored = "2.2.0" dirs = "5.0.1" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.134" +serde_yaml = "0.9" reqwest = { version = "0.12.12", default-features = false, features = [ "rustls-tls", "json", @@ -96,8 +97,14 @@ tokio-util = "0.7.15" similar = "2.7.0" pathdiff = "0.2.3" pastey = "0.1.1" +nix = { version = "0.30.1", features = ["signal"] } +fs2 = "0.4.3" +async-trait = "0.1.89" [profile.release] lto = "fat" opt-level = "z" panic = "abort" + +[dev-dependencies] +tempfile = "3.23.0" diff --git a/src/commands/dev.rs b/src/commands/dev.rs new file mode 100644 index 0000000..278c51a --- /dev/null +++ b/src/commands/dev.rs @@ -0,0 +1,1729 @@ +use std::{ + collections::{BTreeMap, HashMap}, + io::IsTerminal, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use tokio::sync::mpsc; + +use crate::{ + controllers::{ + config::{ServiceInstance, fetch_environment_config}, + develop::{ + CodeServiceConfig, ComposeServiceStatus, DevelopSessionLock, DockerComposeFile, + DockerComposeNetwork, DockerComposeNetworks, DockerComposeService, DockerComposeVolume, + HttpsConfig, HttpsOverride, LocalDevConfig, OverrideMode, PortType, ProcessManager, + PublicDomainMapping, ServicePort, ServiceSummary, build_port_infos, + build_service_endpoints, build_slug_port_mapping, certs_exist, + check_docker_compose_installed, check_mkcert_installed, ensure_mkcert_ca, + generate_caddyfile, generate_certs, generate_port, generate_random_port, + get_compose_path as develop_get_compose_path, get_develop_dir, get_existing_certs, + get_https_mode, is_port_443_available, override_railway_vars, print_log_line, + resolve_path, slugify, volume_name, + }, + project::{self, ensure_project_and_environment_exist}, + variables::get_service_variables, + }, + util::prompt::{prompt_multi_options, prompt_options, prompt_path_with_default, prompt_text}, +}; + +use clap::Subcommand; + +use super::*; + +/// Run Railway services locally +#[derive(Debug, Parser)] +pub struct Args { + #[clap(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum DevelopCommand { + /// Start services (default when no subcommand provided) + Up(UpArgs), + /// Stop services + Down(DownArgs), + /// Stop services and remove volumes/data + Clean(CleanArgs), + /// Configure local code services + Configure(ConfigureArgs), +} + +#[derive(Debug, Parser)] +struct ConfigureArgs { + /// Specific service to configure (by name) + #[clap(long)] + service: Option, + + /// Remove configuration for a service (optionally specify service name) + #[clap(long, num_args = 0..=1, default_missing_value = "")] + remove: Option, +} + +#[derive(Debug, Parser, Default)] +struct UpArgs { + /// Environment to use (defaults to linked environment) + #[clap(short, long)] + environment: Option, + + /// Output path for docker-compose.yml (defaults to ~/.railway/develop//docker-compose.yml) + #[clap(short, long)] + output: Option, + + /// Only generate docker-compose.yml, don't run docker compose up + #[clap(long)] + dry_run: bool, + + /// Disable HTTPS and pretty URLs (use localhost instead) + #[clap(long)] + no_https: bool, +} + +#[derive(Debug, Parser)] +struct DownArgs { + /// Output path for docker-compose.yml (defaults to ~/.railway/develop//docker-compose.yml) + #[clap(short, long)] + output: Option, +} + +#[derive(Debug, Parser)] +struct CleanArgs { + /// Output path for docker-compose.yml (defaults to ~/.railway/develop//docker-compose.yml) + #[clap(short, long)] + output: Option, +} + +pub async fn command(args: Args) -> Result<()> { + eprintln!( + "{}", + "Experimental feature. API may change without notice.".yellow() + ); + + match args.command { + Some(DevelopCommand::Up(args)) => up_command(args).await, + Some(DevelopCommand::Down(args)) => down_command(args).await, + Some(DevelopCommand::Clean(args)) => clean_command(args).await, + Some(DevelopCommand::Configure(args)) => configure_command(args).await, + None => up_command(UpArgs::default()).await, + } +} + +async fn get_compose_path(output: &Option) -> Result { + if let Some(path) = output { + return Ok(path.clone()); + } + + let configs = Configs::new()?; + let linked_project = configs.get_linked_project().await?; + Ok(develop_get_compose_path(&linked_project.project)) +} + +fn docker_install_url() -> &'static str { + match std::env::consts::OS { + "macos" => "https://docs.docker.com/desktop/setup/install/mac-install", + "windows" => "https://docs.docker.com/desktop/setup/install/windows-install", + _ => "https://docs.docker.com/desktop/setup/install/linux", + } +} + +fn require_docker_compose() { + if !check_docker_compose_installed() { + eprintln!(); + eprintln!("{}", "Docker Compose not found.".yellow()); + eprintln!("Install Docker:"); + eprintln!(" {}", docker_install_url()); + std::process::exit(1); + } +} + +async fn down_command(args: DownArgs) -> Result<()> { + require_docker_compose(); + + let compose_path = get_compose_path(&args.output).await?; + + if !compose_path.exists() { + println!("{}", "Services already stopped".green()); + return Ok(()); + } + + println!("{}", "Stopping services...".cyan()); + + let exit_status = tokio::process::Command::new("docker") + .args(["compose", "-f", &*compose_path.to_string_lossy(), "down"]) + .status() + .await?; + + if let Some(code) = exit_status.code() { + if code != 0 { + bail!("docker compose down exited with code {}", code); + } + } + + println!("{}", "Services stopped".green()); + Ok(()) +} + +async fn clean_command(args: CleanArgs) -> Result<()> { + require_docker_compose(); + + let compose_path = get_compose_path(&args.output).await?; + + if !compose_path.exists() { + println!("{}", "Nothing to clean".green()); + return Ok(()); + } + + let confirmed = crate::util::prompt::prompt_confirm_with_default( + "Stop services and remove volume data?", + false, + )?; + if !confirmed { + return Ok(()); + } + + println!("{}", "Cleaning up services...".cyan()); + + let exit_status = tokio::process::Command::new("docker") + .args([ + "compose", + "-f", + &*compose_path.to_string_lossy(), + "down", + "-v", + ]) + .status() + .await?; + + if let Some(code) = exit_status.code() { + if code != 0 { + bail!("docker compose down exited with code {}", code); + } + } + + if let Some(parent) = compose_path.parent() { + std::fs::remove_dir_all(parent)?; + } + + println!("{}", "Services cleaned".green()); + Ok(()) +} + +struct CodeServiceDisplay { + service_id: String, + name: String, + configured: bool, +} + +impl std::fmt::Display for CodeServiceDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.configured { + write!(f, "{} (configured)", self.name) + } else { + write!(f, "{}", self.name) + } + } +} + +async fn configure_command(args: ConfigureArgs) -> Result<()> { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + ensure_project_and_environment_exist(&client, &configs, &linked_project).await?; + + let project_data = + project::get_project(&client, &configs, linked_project.project.clone()).await?; + + let service_names: HashMap = project_data + .services + .edges + .iter() + .map(|e| (e.node.id.clone(), e.node.name.clone())) + .collect(); + + let project_id = linked_project.project.clone(); + let environment_id = linked_project.environment.clone(); + + let env_response = fetch_environment_config(&client, &configs, &environment_id, false).await?; + let config = env_response.config; + + if config.services.is_empty() { + println!( + "{}", + "No services in this environment. Add services with 'railway add'.".yellow() + ); + return Ok(()); + } + + let code_services: Vec<_> = config + .services + .iter() + .filter(|(_, svc)| svc.is_code_based()) + .collect(); + + if code_services.is_empty() { + println!( + "{}", + "No code-based services found. This environment only has image-based services." + .yellow() + ); + return Ok(()); + } + + let mut local_dev_config = LocalDevConfig::load(&project_id)?; + + if let Some(ref remove_arg) = args.remove { + let service_to_remove = if !remove_arg.is_empty() { + // --remove + code_services + .iter() + .find(|(id, _)| { + service_names + .get(*id) + .map(|n| n == remove_arg) + .unwrap_or(false) + }) + .map(|(id, _)| (*id).clone()) + } else { + // --remove (no arg) - prompt for selection + let configured: Vec<_> = code_services + .iter() + .filter(|(id, _)| local_dev_config.services.contains_key(*id)) + .map(|(id, _)| CodeServiceDisplay { + service_id: (*id).clone(), + name: service_names + .get(*id) + .cloned() + .unwrap_or_else(|| (*id).clone()), + configured: true, + }) + .collect(); + + if configured.is_empty() { + println!("{}", "No configured services to remove".yellow()); + return Ok(()); + } + + let selected = prompt_options("Select service to remove configuration:", configured)?; + Some(selected.service_id) + }; + + if let Some(service_id) = service_to_remove { + let name = service_names + .get(&service_id) + .cloned() + .unwrap_or_else(|| service_id.clone()); + if local_dev_config.remove_service(&service_id).is_some() { + local_dev_config.save(&project_id)?; + println!("{} Removed configuration for '{}'", "✓".green(), name); + } else { + println!( + "{}", + format!("Service '{}' is not configured", name).yellow() + ); + } + } + + return Ok(()); + } + + // Service list loop + loop { + let service_id_to_configure = if let Some(ref name) = args.service { + code_services + .iter() + .find(|(id, _)| service_names.get(*id).map(|n| n == name).unwrap_or(false)) + .map(|(id, _)| (*id).clone()) + } else { + let options: Vec<_> = code_services + .iter() + .map(|(id, _)| CodeServiceDisplay { + service_id: (*id).clone(), + name: service_names + .get(*id) + .cloned() + .unwrap_or_else(|| (*id).clone()), + configured: local_dev_config.services.contains_key(*id), + }) + .collect(); + + let selected = prompt_options("Select service to configure:", options)?; + Some(selected.service_id) + }; + + let Some(service_id) = service_id_to_configure else { + return Ok(()); + }; + + let svc = config + .services + .get(&service_id) + .context("Service not found")?; + let name = service_names + .get(&service_id) + .cloned() + .unwrap_or_else(|| service_id.clone()); + + // If no existing config, do initial setup first + if local_dev_config.get_service(&service_id).is_none() { + let mut new_config = prompt_service_config(&name, svc, None)?; + + // Check for port conflicts with existing services + if let Some(port) = new_config.port { + let conflicts: Vec<_> = local_dev_config + .services + .iter() + .filter(|(id, cfg)| *id != &service_id && cfg.port == Some(port)) + .map(|(id, _)| service_names.get(id).cloned().unwrap_or_else(|| id.clone())) + .collect(); + + if !conflicts.is_empty() { + println!( + "\n{} Port {} is already used by: {}", + "Warning:".yellow().bold(), + port, + conflicts.join(", ") + ); + let suggested = generate_random_port(); + let port_input = + prompt_text(&format!("Choose a different port [{}]:", suggested))?; + new_config.port = Some(if port_input.is_empty() { + suggested + } else { + port_input.parse().context("Invalid port number")? + }); + } + } + + local_dev_config.set_service(service_id.clone(), new_config); + local_dev_config.save(&project_id)?; + println!("{} Configured '{}'", "✓".green(), name); + } + + // Service config menu loop + loop { + let action = show_service_config_menu( + &name, + local_dev_config.get_service(&service_id).unwrap(), + )?; + + match action { + ConfigAction::ChangeCommand => { + let existing = local_dev_config.get_service(&service_id).unwrap(); + let new_command = prompt_text(&format!( + "Dev command for '{}' [{}]:", + name, existing.command + )) + .map(|s| { + if s.is_empty() { + existing.command.clone() + } else { + s + } + })?; + let mut updated = existing.clone(); + updated.command = new_command; + local_dev_config.set_service(service_id.clone(), updated); + local_dev_config.save(&project_id)?; + println!("{} Updated command for '{}'", "✓".green(), name); + } + ConfigAction::ChangeDirectory => { + let existing = local_dev_config.get_service(&service_id).unwrap(); + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let default_dir = PathBuf::from(&existing.directory) + .strip_prefix(&cwd) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| existing.directory.clone()); + + let input_path = prompt_path_with_default( + &format!("Directory for '{}' (relative to cwd):", name), + &default_dir, + )?; + + let directory = if input_path.is_absolute() { + input_path.to_string_lossy().to_string() + } else { + resolve_path(cwd.join(&input_path)) + .to_string_lossy() + .to_string() + }; + + let mut updated = existing.clone(); + updated.directory = directory; + local_dev_config.set_service(service_id.clone(), updated); + local_dev_config.save(&project_id)?; + println!("{} Updated directory for '{}'", "✓".green(), name); + } + ConfigAction::ChangePort => { + let existing = local_dev_config.get_service(&service_id).unwrap(); + let railway_port = svc.get_ports().first().map(|&p| p as u16); + let current_port = existing.port.or(railway_port).unwrap_or(3000); + + let port_input = + prompt_text(&format!("Port for '{}' [{}]:", name, current_port))?; + + let mut new_port = if port_input.is_empty() { + current_port + } else { + port_input.parse().context("Invalid port number")? + }; + + // Check for port conflicts + let conflicts: Vec<_> = local_dev_config + .services + .iter() + .filter(|(id, cfg)| *id != &service_id && cfg.port == Some(new_port)) + .map(|(id, _)| service_names.get(id).cloned().unwrap_or_else(|| id.clone())) + .collect(); + + if !conflicts.is_empty() { + println!( + "\n{} Port {} is already used by: {}", + "Warning:".yellow().bold(), + new_port, + conflicts.join(", ") + ); + let suggested = generate_random_port(); + let port_input = + prompt_text(&format!("Choose a different port [{}]:", suggested))?; + new_port = if port_input.is_empty() { + suggested + } else { + port_input.parse().context("Invalid port number")? + }; + } + + let mut updated = existing.clone(); + updated.port = Some(new_port); + local_dev_config.set_service(service_id.clone(), updated); + local_dev_config.save(&project_id)?; + println!("{} Updated port for '{}'", "✓".green(), name); + } + ConfigAction::Remove => { + local_dev_config.remove_service(&service_id); + local_dev_config.save(&project_id)?; + println!("{} Removed configuration for '{}'", "✓".green(), name); + break; // Back to service list + } + ConfigAction::Back => { + break; // Back to service list + } + } + } + + // If --service was specified, exit after handling that service + if args.service.is_some() { + return Ok(()); + } + } +} + +#[derive(Debug, Clone, Copy)] +enum ConfigAction { + ChangeCommand, + ChangeDirectory, + ChangePort, + Remove, + Back, +} + +impl std::fmt::Display for ConfigAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigAction::ChangeCommand => write!(f, "Change command"), + ConfigAction::ChangeDirectory => write!(f, "Change directory"), + ConfigAction::ChangePort => write!(f, "Change port"), + ConfigAction::Remove => write!(f, "Remove configuration"), + ConfigAction::Back => write!(f, "← Configure another service"), + } + } +} + +fn show_service_config_menu(name: &str, config: &CodeServiceConfig) -> Result { + let cwd = std::env::current_dir().ok(); + let display_dir = cwd + .as_ref() + .and_then(|cwd| { + PathBuf::from(&config.directory) + .strip_prefix(cwd) + .ok() + .map(|p| format!("./{}", p.display())) + }) + .unwrap_or_else(|| config.directory.clone()); + + println!("\n{}", format!("Service '{}'", name).cyan().bold()); + println!(" {}: {}", "command".dimmed(), config.command); + println!(" {}: {}", "directory".dimmed(), display_dir); + if let Some(port) = config.port { + println!(" {}: {}", "port".dimmed(), port); + } + println!(); + + let options = vec![ + ConfigAction::ChangeCommand, + ConfigAction::ChangeDirectory, + ConfigAction::ChangePort, + ConfigAction::Remove, + ConfigAction::Back, + ]; + + prompt_options("", options) +} + +fn prompt_service_config( + name: &str, + svc: &ServiceInstance, + existing: Option<&CodeServiceConfig>, +) -> Result { + println!("\n{}", format!("Configure '{}'", name).cyan().bold()); + + let default_command = existing.map(|e| e.command.as_str()).unwrap_or(""); + let command = if default_command.is_empty() { + prompt_text(&format!("Dev command for '{}':", name))? + } else { + prompt_text(&format!( + "Dev command for '{}' [{}]:", + name, default_command + )) + .map(|s| { + if s.is_empty() { + default_command.to_string() + } else { + s + } + })? + }; + + // For existing configs, show relative to cwd; for new configs, default to "." + let cwd = std::env::current_dir().context("Failed to get current directory")?; + let default_dir = existing + .map(|e| { + // Try to make existing absolute path relative to cwd for display + PathBuf::from(&e.directory) + .strip_prefix(&cwd) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| e.directory.clone()) + }) + .unwrap_or_else(|| ".".to_string()); + + let input_path = prompt_path_with_default( + &format!("Directory for '{}' (relative to current directory):", name), + &default_dir, + )?; + + let directory = if input_path.is_absolute() { + input_path.to_string_lossy().to_string() + } else { + resolve_path(cwd.join(&input_path)) + .to_string_lossy() + .to_string() + }; + + // Prompt for port if service has networking config + let inferred_port = svc.get_ports().first().map(|&p| p as u16); + let default_port = existing.and_then(|e| e.port).or(inferred_port); + + let port = if let Some(default) = default_port { + let port_input = prompt_text(&format!("Port for '{}' [{}]:", name, default))?; + if port_input.is_empty() { + Some(default) + } else { + Some(port_input.parse().context("Invalid port number")?) + } + } else { + None + }; + + Ok(CodeServiceConfig { + command, + directory, + port, + }) +} + +/// Prompts user to select and configure multiple services at once +fn prompt_initial_service_setup( + code_services: &[(&String, &ServiceInstance)], + service_names: &HashMap, + config: &crate::controllers::config::EnvironmentConfig, + local_dev_config: &mut LocalDevConfig, +) -> Result<()> { + println!("\n{}", "Configure local code services".cyan().bold()); + println!("{}", "(Press space to select, enter to confirm)".dimmed()); + + let options: Vec<_> = code_services + .iter() + .map(|(id, _)| CodeServiceDisplay { + service_id: (*id).clone(), + name: service_names + .get(*id) + .cloned() + .unwrap_or_else(|| (*id).clone()), + configured: false, + }) + .collect(); + + let selected = prompt_multi_options("Select services to configure:", options)?; + + for service_display in &selected { + let svc = config + .services + .get(&service_display.service_id) + .context("Service not found")?; + let name = &service_display.name; + + let mut new_config = prompt_service_config(name, svc, None)?; + + // Check for port conflicts with already-configured services + if let Some(port) = new_config.port { + let conflicts: Vec<_> = local_dev_config + .services + .iter() + .filter(|(id, cfg)| *id != &service_display.service_id && cfg.port == Some(port)) + .map(|(id, _)| service_names.get(id).cloned().unwrap_or_else(|| id.clone())) + .collect(); + + if !conflicts.is_empty() { + println!( + "\n{} Port {} is already used by: {}", + "Warning:".yellow().bold(), + port, + conflicts.join(", ") + ); + let suggested = generate_random_port(); + let port_input = prompt_text(&format!("Choose a different port [{}]:", suggested))?; + new_config.port = Some(if port_input.is_empty() { + suggested + } else { + port_input.parse().context("Invalid port number")? + }); + } + } + + local_dev_config.set_service(service_display.service_id.clone(), new_config); + } + + // Show summary if any services were configured + if !selected.is_empty() { + println!("\n{}", "Configured services:".green().bold()); + for service_display in &selected { + if let Some(cfg) = local_dev_config.get_service(&service_display.service_id) { + let port_str = cfg + .port + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".to_string()); + println!( + " {} {} (port {})", + "•".dimmed(), + service_display.name.cyan(), + port_str + ); + } + } + } + + Ok(()) +} + +/// Returns a list of (port, service_names) for ports that have multiple services +fn detect_port_conflicts( + configs: &HashMap, + service_names: &HashMap, +) -> Vec<(u16, Vec)> { + let mut port_to_services: HashMap> = HashMap::new(); + + for (service_id, config) in configs { + if let Some(port) = config.port { + let name = service_names + .get(service_id) + .cloned() + .unwrap_or_else(|| service_id.clone()); + port_to_services.entry(port).or_default().push(name); + } + } + + port_to_services + .into_iter() + .filter(|(_, services)| services.len() > 1) + .collect() +} + +/// Detects and resolves port conflicts. Returns true if conflicts were resolved, false if none. +fn resolve_port_conflicts( + local_dev_config: &mut LocalDevConfig, + service_names: &HashMap, + project_id: &str, +) -> Result { + let conflicts = detect_port_conflicts(&local_dev_config.services, service_names); + if conflicts.is_empty() { + return Ok(false); + } + + if !std::io::stdout().is_terminal() { + for (port, services) in &conflicts { + eprintln!( + "{} Port {} is used by multiple services: {}", + "Error:".red().bold(), + port, + services.join(", ") + ); + } + anyhow::bail!("Port conflicts detected. Run 'railway develop configure' to resolve."); + } + + println!("\n{} Port conflicts detected:", "Warning:".yellow().bold()); + for (port, services) in &conflicts { + println!(" Port {}: {}", port, services.join(", ")); + } + println!(); + + // Prompt to resolve each conflict - skip first service, reconfigure the rest + for (port, conflicting_services) in conflicts { + for service_name in conflicting_services.iter().skip(1) { + let service_id = service_names + .iter() + .find(|(_, name)| *name == service_name) + .map(|(id, _)| id.clone()); + + if let Some(service_id) = service_id { + let suggested = generate_random_port(); + let port_input = prompt_text(&format!( + "New port for '{}' (currently {}) [{}]:", + service_name, port, suggested + ))?; + + let new_port = if port_input.is_empty() { + suggested + } else { + port_input.parse().context("Invalid port number")? + }; + + if let Some(mut cfg) = local_dev_config.get_service(&service_id).cloned() { + cfg.port = Some(new_port); + local_dev_config.set_service(service_id, cfg); + } + } + } + } + local_dev_config.save(project_id)?; + println!(); + Ok(true) +} + +async fn up_command(args: UpArgs) -> Result<()> { + require_docker_compose(); + + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + ensure_project_and_environment_exist(&client, &configs, &linked_project).await?; + + let project_data = + project::get_project(&client, &configs, linked_project.project.clone()).await?; + + let service_names: HashMap = project_data + .services + .edges + .iter() + .map(|e| (e.node.id.clone(), e.node.name.clone())) + .collect(); + + let project_id = linked_project.project.clone(); + let environment_id = args + .environment + .clone() + .unwrap_or(linked_project.environment.clone()); + + let env_response = fetch_environment_config(&client, &configs, &environment_id, true).await?; + let env_name = env_response.name; + let config = env_response.config; + + let service_slugs = build_service_endpoints(&service_names, &config); + + let image_services: Vec<_> = config + .services + .iter() + .filter(|(_, svc)| svc.is_image_based()) + .collect(); + + let code_services: Vec<_> = config + .services + .iter() + .filter(|(_, svc)| svc.is_code_based()) + .collect(); + + let mut local_dev_config = LocalDevConfig::load(&project_id)?; + let config_file_exists = LocalDevConfig::path(&project_id).exists(); + + // Only prompt for first-time setup (no local-dev.json file yet) + if !config_file_exists && !code_services.is_empty() && std::io::stdout().is_terminal() { + prompt_initial_service_setup( + &code_services, + &service_names, + &config, + &mut local_dev_config, + )?; + local_dev_config.save(&project_id)?; + println!(); + } + + let configured_code_services: Vec<_> = code_services + .iter() + .filter(|(id, _)| local_dev_config.services.contains_key(*id)) + .collect(); + + // Check for and resolve port conflicts among configured code services + resolve_port_conflicts(&mut local_dev_config, &service_names, &project_id)?; + + if image_services.is_empty() && configured_code_services.is_empty() { + if config.services.is_empty() { + println!(); + println!("No services in environment {}", env_name.blue().bold()); + println!("Add services with {}", "railway add".cyan()); + } else { + println!(); + println!( + "No services to run in environment {}", + env_name.blue().bold() + ); + println!( + "Use {} to set up code services", + "railway develop configure".cyan() + ); + } + println!(); + return Ok(()); + } + + let https_config = if args.no_https { + None + } else { + setup_https(&project_data.name, &project_id)? + }; + + // Maps service slug -> (internal_port -> external_port) for variable substitution. + // Used to rewrite "{slug}.railway.internal:{port}" refs in any service's variables. + let mut slug_port_mappings: HashMap> = image_services + .iter() + .filter_map(|(service_id, svc)| { + let slug = service_slugs.get(*service_id)?; + let ports = build_slug_port_mapping(service_id, svc); + Some((slug.clone(), ports)) + }) + .collect(); + + // Code services run on host, not Docker - all configured ports map to the single bound port + for (service_id, svc) in &configured_code_services { + if let Some(slug) = service_slugs.get(*service_id) { + if let Some(dev_config) = local_dev_config.get_service(service_id) { + let internal_port = dev_config + .port + .map(|p| p as i64) + .or_else(|| svc.get_ports().first().copied()) + .unwrap_or(3000); + let mut mapping = HashMap::new(); + for port in svc.get_ports() { + mapping.insert(port, internal_port as u16); + } + mapping.insert(internal_port, internal_port as u16); + slug_port_mappings.insert(slug.clone(), mapping); + } + } + } + + let variable_futures: Vec<_> = image_services + .iter() + .map(|(service_id, _)| { + get_service_variables( + &client, + &configs, + linked_project.project.clone(), + environment_id.clone(), + (*service_id).clone(), + ) + }) + .collect(); + + let variable_results = futures::future::join_all(variable_futures).await; + + let resolved_vars: HashMap> = image_services + .iter() + .zip(variable_results.into_iter()) + .filter_map(|((service_id, _), result)| { + result.ok().map(|vars| ((*service_id).clone(), vars)) + }) + .collect(); + + let mut public_domain_mapping = build_public_domain_mapping( + &image_services, + &resolved_vars, + &service_names, + &https_config, + ); + + let compose_result = build_image_service_compose( + &image_services, + &service_names, + &service_slugs, + &slug_port_mappings, + &public_domain_mapping, + &resolved_vars, + &https_config, + &environment_id, + ); + + let mut compose_services = compose_result.services; + let compose_volumes = compose_result.volumes; + let service_summaries = compose_result.summaries; + let service_count = compose_services.len(); + + if let Some(ref config) = https_config { + setup_caddy_proxy( + &mut compose_services, + &service_summaries, + &configured_code_services, + &local_dev_config, + &service_slugs, + config, + &project_id, + )?; + } + + let compose = DockerComposeFile { + services: compose_services, + networks: Some(DockerComposeNetworks { + railway: DockerComposeNetwork { + driver: "bridge".to_string(), + }, + }), + volumes: compose_volumes, + }; + + let output_path = args + .output + .unwrap_or_else(|| get_develop_dir(&project_id).join("docker-compose.yml")); + + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let yaml = serde_yaml::to_string(&compose)?; + + let tmp_path = output_path.with_extension("yml.tmp"); + std::fs::write(&tmp_path, &yaml)?; + std::fs::rename(&tmp_path, &output_path)?; + + if args.dry_run { + println!("\n{} {}", "Generated".green(), output_path.display()); + println!("\n{}", "Dry run mode, not starting services".yellow()); + println!("\nTo start manually:"); + println!(" docker compose -f {} up", output_path.display()); + return Ok(()); + } + + if !image_services.is_empty() { + println!("{}", "Starting image services...".cyan()); + + let output_path_str = output_path.to_string_lossy(); + let exit_status = tokio::process::Command::new("docker") + .args(["compose", "-f", &*output_path_str, "up", "-d"]) + .status() + .await?; + + if let Some(code) = exit_status.code() { + if code != 0 { + bail!("docker compose exited with code {}", code); + } + } + + // Wait for containers before starting code services that depend on them + if !configured_code_services.is_empty() { + println!("\n{}", "Waiting for services to be ready...".dimmed()); + wait_for_services(&output_path, Duration::from_secs(60)).await?; + } + } + + if !service_summaries.is_empty() { + let svc_word = if service_count == 1 { + "service" + } else { + "services" + }; + println!( + " {} Started {} image {}", + "✓".green(), + service_count, + svc_word + ); + println!(); + + for summary in &service_summaries { + print_image_service_summary(summary, &https_config); + } + } + + if configured_code_services.is_empty() { + print_next_steps(&code_services, &service_names); + return Ok(()); + } + + let _session_lock = DevelopSessionLock::try_acquire(&project_id)?; + + println!("{}", "Starting code services...".cyan()); + + let (log_tx, mut log_rx) = mpsc::channel(100); + let mut process_manager = ProcessManager::new(); + + let code_var_futures: Vec<_> = configured_code_services + .iter() + .map(|(service_id, _)| { + get_service_variables( + &client, + &configs, + linked_project.project.clone(), + environment_id.clone(), + (*service_id).clone(), + ) + }) + .collect(); + + let code_var_results = futures::future::join_all(code_var_futures).await; + + let code_resolved_vars: HashMap> = configured_code_services + .iter() + .zip(code_var_results.into_iter()) + .filter_map(|((service_id, _), result)| { + result.ok().map(|vars| ((*service_id).clone(), vars)) + }) + .collect(); + + // Extend public domain mapping with code services + for (service_id, svc) in &configured_code_services { + if let Some(vars) = code_resolved_vars.get(*service_id) { + if let Some(prod_domain) = vars.get("RAILWAY_PUBLIC_DOMAIN") { + let service_name = service_names + .get(*service_id) + .cloned() + .unwrap_or_else(|| (*service_id).clone()); + let slug = slugify(&service_name); + let dev_config = local_dev_config.get_service(service_id); + let internal_port = dev_config + .and_then(|c| c.port.map(|p| p as i64)) + .or_else(|| svc.get_ports().first().copied()); + let proxy_port = internal_port.map(|p| generate_port(service_id, p)); + + let local_domain = match (&https_config, proxy_port) { + (Some(config), Some(_)) if config.use_port_443 => { + format!("{}.{}", slug, config.base_domain) + } + (Some(config), Some(port)) => { + format!("{}:{}", config.base_domain, port) + } + (None, Some(port)) => format!("localhost:{}", port), + _ => "localhost".to_string(), + }; + public_domain_mapping.insert(prod_domain.clone(), local_domain); + } + } + } + + for (service_id, svc) in &configured_code_services { + let dev_config = match local_dev_config.get_service(service_id) { + Some(c) => c, + None => continue, + }; + + let service_name = service_names + .get(*service_id) + .cloned() + .unwrap_or_else(|| (*service_id).clone()); + let slug = slugify(&service_name); + + let working_dir = PathBuf::from(&dev_config.directory); + + // Get port info for this service (only if networking is configured) + // internal_port: what the process binds to (for private domain, direct localhost access) + // proxy_port: what Caddy exposes (for public domain, HTTPS access) + let internal_port = dev_config + .port + .map(|p| p as i64) + .or_else(|| svc.get_ports().first().copied()); + let proxy_port = internal_port.map(|p| generate_port(service_id, p)); + + // Port mapping for private domain refs - map to internal_port (direct localhost) + let mut port_mapping = HashMap::new(); + if let Some(port) = internal_port { + for p in svc.get_ports() { + port_mapping.insert(p, port as u16); + } + port_mapping.insert(port, port as u16); + } + + // Get and transform variables + let raw_vars = code_resolved_vars + .get(*service_id) + .cloned() + .unwrap_or_default(); + + // HttpsOverride uses proxy_port for RAILWAY_PUBLIC_DOMAIN + let https_override = match (&https_config, proxy_port) { + (Some(config), Some(port)) => Some(HttpsOverride { + domain: &config.base_domain, + port, + slug: Some(slug.clone()), + use_port_443: config.use_port_443, + }), + _ => None, + }; + + let mut vars = override_railway_vars( + raw_vars, + &slug, + &port_mapping, + &service_slugs, + &slug_port_mappings, + &public_domain_mapping, + OverrideMode::HostNetwork, + https_override, + ); + + // Only set PORT if service has networking configured + if let Some(port) = internal_port { + vars.insert("PORT".to_string(), port.to_string()); + } + + print_code_service_summary( + &service_name, + &dev_config.command, + &working_dir, + vars.len(), + internal_port, + proxy_port, + &https_config, + ); + + process_manager + .spawn_service( + service_name, + &dev_config.command, + working_dir, + vars, + log_tx.clone(), + ) + .await?; + } + + // Drop the original sender so the channel closes when all processes exit + drop(log_tx); + + println!("{}", "Streaming logs (Ctrl+C to stop)...".dimmed()); + println!(); + + loop { + tokio::select! { + Some(log) = log_rx.recv() => { + print_log_line(&log); + } + _ = tokio::signal::ctrl_c() => { + eprintln!("\n{}", "Shutting down...".yellow()); + break; + } + } + } + + process_manager.shutdown().await; + + if !image_services.is_empty() { + println!("{}", "Stopping image services...".cyan()); + let _ = tokio::process::Command::new("docker") + .args(["compose", "-f", &*output_path.to_string_lossy(), "down"]) + .status() + .await; + } + + println!("{}", "All services stopped".green()); + Ok(()) +} + +fn print_next_steps( + unconfigured_code_services: &[(&String, &ServiceInstance)], + service_names: &HashMap, +) { + println!("{}", "Next steps".cyan().bold()); + println!(); + + println!( + " {} Run a command with access to these services:", + "•".dimmed() + ); + println!(" {}", "railway run ".cyan()); + println!(); + + if !unconfigured_code_services.is_empty() { + println!(" {} Configure code services to run locally:", "•".dimmed()); + println!(" {}", "railway dev configure".cyan()); + println!(); + println!(" {}", "Available:".dimmed()); + for (id, _) in unconfigured_code_services { + if let Some(name) = service_names.get(*id) { + println!(" {} {}", "·".dimmed(), name); + } + } + println!(); + } +} + +fn print_image_service_summary(summary: &ServiceSummary, https_config: &Option) { + println!("{}", summary.name.green().bold()); + println!(" {}: {}", "Image".dimmed(), summary.image); + println!( + " {}: {} variables", + "Variables".dimmed(), + summary.var_count + ); + if !summary.ports.is_empty() { + println!(" {}:", "Networking".dimmed()); + let slug = slugify(&summary.name); + for p in &summary.ports { + match (https_config, &p.port_type) { + (Some(config), PortType::Http) => { + println!( + " {}: http://localhost:{}", + "Private".dimmed(), + p.external + ); + if config.use_port_443 { + println!( + " {}: https://{}.{}", + "Public".dimmed(), + slug, + config.base_domain + ); + } else { + println!( + " {}: https://{}:{}", + "Public".dimmed(), + config.base_domain, + p.public_port + ); + } + } + (_, PortType::Tcp) => { + println!(" {}: localhost:{}", "TCP".dimmed(), p.external); + } + (None, PortType::Http) => { + println!(" http://localhost:{}", p.external); + } + } + } + } + if !summary.volumes.is_empty() { + let label = if summary.volumes.len() == 1 { + "Volume" + } else { + "Volumes" + }; + println!(" {}: {}", label.dimmed(), summary.volumes.join(", ")); + } + println!(); +} + +fn print_code_service_summary( + service_name: &str, + command: &str, + working_dir: &Path, + var_count: usize, + internal_port: Option, + proxy_port: Option, + https_config: &Option, +) { + let slug = slugify(service_name); + println!("{}", service_name.green().bold()); + println!(" {}: {}", "Command".dimmed(), command); + println!(" {}: {}", "Directory".dimmed(), working_dir.display()); + println!(" {}: {} variables", "Variables".dimmed(), var_count); + if let (Some(port), Some(pport)) = (internal_port, proxy_port) { + println!(" {}:", "Networking".dimmed()); + match https_config { + Some(config) => { + println!(" {}: http://localhost:{}", "Private".dimmed(), port); + if config.use_port_443 { + println!( + " {}: https://{}.{}", + "Public".dimmed(), + slug, + config.base_domain + ); + } else { + println!( + " {}: https://{}:{}", + "Public".dimmed(), + config.base_domain, + pport + ); + } + } + None => { + println!(" http://localhost:{}", port); + } + } + } + println!(); +} + +fn build_public_domain_mapping( + services: &[(&String, &ServiceInstance)], + resolved_vars: &HashMap>, + service_names: &HashMap, + https_config: &Option, +) -> PublicDomainMapping { + let mut mapping: PublicDomainMapping = HashMap::new(); + + for (service_id, svc) in services { + if let Some(vars) = resolved_vars.get(*service_id) { + if let Some(prod_domain) = vars.get("RAILWAY_PUBLIC_DOMAIN") { + let service_name = service_names + .get(*service_id) + .cloned() + .unwrap_or_else(|| (*service_id).clone()); + let slug = slugify(&service_name); + let port_infos = build_port_infos(service_id, svc); + + let local_domain = match https_config { + Some(config) if config.use_port_443 => { + format!("{}.{}", slug, config.base_domain) + } + Some(config) => { + let port = port_infos + .iter() + .find(|p| matches!(p.port_type, PortType::Http)) + .map(|p| p.public_port) + .unwrap_or(443); + format!("{}:{}", config.base_domain, port) + } + None => { + let port = port_infos + .iter() + .find(|p| matches!(p.port_type, PortType::Http)) + .map(|p| p.external) + .unwrap_or(3000); + format!("localhost:{}", port) + } + }; + mapping.insert(prod_domain.clone(), local_domain); + } + } + } + + mapping +} + +struct ImageServiceComposeResult { + services: BTreeMap, + volumes: BTreeMap, + summaries: Vec, +} + +#[allow(clippy::too_many_arguments)] +fn build_image_service_compose( + image_services: &[(&String, &ServiceInstance)], + service_names: &HashMap, + service_slugs: &HashMap, + slug_port_mappings: &HashMap>, + public_domain_mapping: &PublicDomainMapping, + resolved_vars: &HashMap>, + https_config: &Option, + environment_id: &str, +) -> ImageServiceComposeResult { + let mut compose_services = BTreeMap::new(); + let mut compose_volumes = BTreeMap::new(); + let mut service_summaries = Vec::new(); + + for (service_id, svc) in image_services { + let service_name = service_names + .get(*service_id) + .cloned() + .unwrap_or_else(|| (*service_id).clone()); + let slug = slugify(&service_name); + + let image = svc.source.as_ref().unwrap().image.clone().unwrap(); + + let port_infos = build_port_infos(service_id, svc); + let port_mapping: HashMap = port_infos + .iter() + .map(|p| (p.internal, p.external)) + .collect(); + + let raw_vars = resolved_vars.get(*service_id).cloned().unwrap_or_default(); + + let https_override = https_config.as_ref().and_then(|config| { + port_infos + .iter() + .find(|p| matches!(p.port_type, PortType::Http)) + .map(|p| HttpsOverride { + domain: &config.base_domain, + port: p.public_port, + slug: Some(slug.clone()), + use_port_443: config.use_port_443, + }) + }); + + let environment = override_railway_vars( + raw_vars, + &slug, + &port_mapping, + service_slugs, + slug_port_mappings, + public_domain_mapping, + OverrideMode::DockerNetwork, + https_override, + ); + + let ports: Vec = port_infos + .iter() + .map(|p| format!("{}:{}", p.external, p.internal)) + .collect(); + + let mut service_volumes = Vec::new(); + for (vol_id, vol_mount) in &svc.volume_mounts { + if let Some(mount_path) = &vol_mount.mount_path { + let vol_name = volume_name(environment_id, vol_id); + service_volumes.push(format!("{}:{}", vol_name, mount_path)); + compose_volumes.insert(vol_name, DockerComposeVolume {}); + } + } + + let start_command = svc + .deploy + .as_ref() + .and_then(|d| d.start_command.clone()) + .map(|cmd| cmd.replace('$', "$$")); + + let volume_paths: Vec = svc + .volume_mounts + .values() + .filter_map(|v| v.mount_path.clone()) + .collect(); + + service_summaries.push(ServiceSummary { + name: service_name, + image: image.clone(), + var_count: environment.len(), + ports: port_infos, + volumes: volume_paths, + }); + + compose_services.insert( + slug, + DockerComposeService { + image, + command: start_command, + restart: Some("on-failure".to_string()), + environment, + ports, + volumes: service_volumes, + networks: vec!["railway".to_string()], + extra_hosts: Vec::new(), + }, + ); + } + + ImageServiceComposeResult { + services: compose_services, + volumes: compose_volumes, + summaries: service_summaries, + } +} + +fn setup_caddy_proxy( + compose_services: &mut BTreeMap, + service_summaries: &[ServiceSummary], + configured_code_services: &[&(&String, &ServiceInstance)], + local_dev_config: &LocalDevConfig, + service_slugs: &HashMap, + https_config: &HttpsConfig, + project_id: &str, +) -> Result<()> { + let mut service_ports: Vec = service_summaries + .iter() + .flat_map(|s| { + s.ports.iter().map(|p| ServicePort { + slug: slugify(&s.name), + internal_port: p.internal, + external_port: p.public_port, + is_http: matches!(p.port_type, PortType::Http), + is_code_service: false, + }) + }) + .collect(); + + for &(service_id, svc) in configured_code_services { + if let Some(dev_config) = local_dev_config.get_service(service_id) { + let slug = service_slugs + .get(*service_id) + .cloned() + .unwrap_or_else(|| slugify(service_id)); + let internal_port = dev_config + .port + .map(|p| p as i64) + .or_else(|| svc.get_ports().first().copied()) + .unwrap_or(3000); + let proxy_port = generate_port(service_id, internal_port); + + service_ports.push(ServicePort { + slug, + internal_port, + external_port: proxy_port, + is_http: true, + is_code_service: true, + }); + } + } + + let proxy_ports: Vec = if https_config.use_port_443 { + vec!["443:443".to_string()] + } else { + service_ports + .iter() + .filter(|p| p.is_http) + .map(|p| format!("{}:{}", p.external_port, p.external_port)) + .collect() + }; + + if !proxy_ports.is_empty() { + compose_services.insert( + "railway-proxy".to_string(), + DockerComposeService { + image: "caddy:2-alpine".to_string(), + command: None, + restart: Some("on-failure".to_string()), + environment: BTreeMap::new(), + ports: proxy_ports, + volumes: vec![ + "./Caddyfile:/etc/caddy/Caddyfile:ro".to_string(), + "./certs:/certs:ro".to_string(), + ], + networks: vec!["railway".to_string()], + extra_hosts: vec!["host.docker.internal:host-gateway".to_string()], + }, + ); + } + + let develop_dir = get_develop_dir(project_id); + std::fs::create_dir_all(&develop_dir)?; + + let caddyfile = generate_caddyfile(&service_ports, https_config); + std::fs::write(develop_dir.join("Caddyfile"), caddyfile)?; + std::fs::write(develop_dir.join("https_domain"), &https_config.base_domain)?; + + Ok(()) +} + +async fn wait_for_services(compose_path: &Path, timeout: Duration) -> Result<()> { + let start = Instant::now(); + + loop { + if start.elapsed() > timeout { + bail!("Timeout waiting for services to be ready"); + } + + let output = tokio::process::Command::new("docker") + .args([ + "compose", + "-f", + &*compose_path.to_string_lossy(), + "ps", + "--format", + "json", + ]) + .output() + .await?; + + let services: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + + for s in &services { + if s.state == "exited" && s.exit_code != 0 { + bail!("Service '{}' exited with code {}", s.service, s.exit_code); + } + } + + let all_ready = services.iter().all(|s| { + if !s.health.is_empty() { + s.health == "healthy" + } else { + s.state == "running" || (s.state == "exited" && s.exit_code == 0) + } + }); + + if all_ready { + return Ok(()); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +fn setup_https(project_name: &str, project_id: &str) -> Result> { + use colored::Colorize; + + if !check_mkcert_installed() { + println!("{}", "mkcert not found, falling back to HTTP mode".yellow()); + println!("Install mkcert for HTTPS support: https://github.com/FiloSottile/mkcert"); + return Ok(None); + } + + // Check if we're already in port 443 mode, or if port 443 is available + let use_port_443 = get_https_mode(project_id) || is_port_443_available(); + + let project_slug = slugify(project_name); + let certs_dir = get_develop_dir(project_id).join("certs"); + + // Check if certs already exist with the right mode + let config = if certs_exist(&certs_dir, use_port_443) { + get_existing_certs(&project_slug, &certs_dir, use_port_443) + } else { + println!("{}", "Setting up local HTTPS...".cyan()); + + // Ensure CA is installed + if let Err(e) = ensure_mkcert_ca() { + println!("{}: {}", "Warning: Failed to install mkcert CA".yellow(), e); + println!("Run 'mkcert -install' manually to trust local certificates"); + } + + match generate_certs(&project_slug, &certs_dir, use_port_443) { + Ok(config) => { + if use_port_443 { + println!( + " {} Generated wildcard certs for *.{}", + "✓".green(), + config.base_domain + ); + } else { + println!( + " {} Generated certs for {}", + "✓".green(), + config.base_domain + ); + } + config + } + Err(e) => { + println!( + "{}: {}", + "Warning: Failed to generate certificates".yellow(), + e + ); + println!("Falling back to HTTP mode"); + return Ok(None); + } + } + }; + + Ok(Some(config)) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 566c459..d8ebbdd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -13,6 +13,7 @@ pub mod completion; pub mod connect; pub mod deploy; pub mod deployment; +pub mod dev; pub mod docs; pub mod domain; pub mod down; diff --git a/src/commands/run.rs b/src/commands/run.rs index 17e65cf..e5328cb 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -4,6 +4,9 @@ use is_terminal::IsTerminal; use crate::{ controllers::{ environment::get_matched_environment, + local_override::{ + apply_local_overrides, build_local_override_context, is_local_develop_active, + }, project::{ensure_project_and_environment_exist, get_project}, variables::get_service_variables, }, @@ -24,6 +27,10 @@ pub struct Args { #[clap(short, long)] environment: Option, + /// Skip local develop overrides even if docker-compose.yml exists + #[clap(long)] + no_local: bool, + /// Args to pass to the command #[clap(trailing_var_arg = true)] args: Vec, @@ -89,17 +96,24 @@ pub async fn command(args: Args) -> Result<()> { .unwrap_or(linked_project.environment.clone()); let environment_id = get_matched_environment(&project, environment)?.id; - let service = get_service(&configs, &project, args.service).await?; + let service = get_service(&configs, &project, args.service.clone()).await?; - let variables = get_service_variables( + let mut variables = get_service_variables( &client, &configs, linked_project.project.clone(), - environment_id, - service, + environment_id.clone(), + service.clone(), ) .await?; + if !args.no_local && is_local_develop_active(&project.id) { + let ctx = + build_local_override_context(&client, &configs, &project, &environment_id).await?; + variables = apply_local_overrides(variables, &service, &ctx); + eprintln!("{}", "Using local develop services".yellow()); + } + // a bit janky :/ ctrlc::set_handler(move || { // do nothing, we just want to ignore CTRL+C diff --git a/src/controllers/config/environment.rs b/src/controllers/config/environment.rs new file mode 100644 index 0000000..20e99cf --- /dev/null +++ b/src/controllers/config/environment.rs @@ -0,0 +1,183 @@ +// Fields on deserialization structs may not all be read +#![allow(dead_code)] + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use reqwest::Client; +use serde::Deserialize; + +use crate::{client::post_graphql, config::Configs, gql::queries}; + +/// Root environment config from `environment.config` GraphQL field +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentConfig { + #[serde(default)] + pub services: BTreeMap, + #[serde(default)] + pub shared_variables: BTreeMap, + #[serde(default)] + pub volumes: BTreeMap, + #[serde(default)] + pub buckets: BTreeMap, + #[serde(default)] + pub private_network_disabled: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ServiceInstance { + #[serde(default)] + pub source: Option, + #[serde(default)] + pub networking: Option, + #[serde(default)] + pub variables: BTreeMap, + #[serde(default)] + pub deploy: Option, + #[serde(default)] + pub build: Option, + #[serde(default)] + pub volume_mounts: BTreeMap, + #[serde(default)] + pub is_deleted: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ServiceSource { + pub image: Option, + pub repo: Option, + pub branch: Option, + pub root_directory: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ServiceNetworking { + #[serde(default)] + pub service_domains: BTreeMap>, + #[serde(default)] + pub custom_domains: BTreeMap>, + #[serde(default)] + pub tcp_proxies: BTreeMap>, + pub private_network_endpoint: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct DomainConfig { + pub port: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct TcpProxyConfig {} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Variable { + pub value: Option, + pub default_value: Option, + pub description: Option, + pub is_optional: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DeployConfig { + pub start_command: Option, + pub healthcheck_path: Option, + pub num_replicas: Option, + pub cron_schedule: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BuildConfig { + pub builder: Option, + pub build_command: Option, + pub dockerfile_path: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct VolumeInstance { + pub size_mb: Option, + pub region: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BucketInstance { + pub region: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct VolumeMount { + pub mount_path: Option, +} + +impl ServiceInstance { + pub fn is_image_based(&self) -> bool { + self.source + .as_ref() + .is_some_and(|s| s.image.is_some() && s.repo.is_none()) + } + + pub fn is_code_based(&self) -> bool { + self.source.as_ref().is_none_or(|s| s.image.is_none()) + } + + pub fn get_ports(&self) -> Vec { + let mut ports = Vec::new(); + if let Some(networking) = &self.networking { + for config in networking.service_domains.values().flatten() { + if let Some(port) = config.port { + if !ports.contains(&port) { + ports.push(port); + } + } + } + for port_str in networking.tcp_proxies.keys() { + if let Ok(port) = port_str.parse::() { + if !ports.contains(&port) { + ports.push(port); + } + } + } + } + ports + } +} + +/// Response from fetch_environment_config containing config and metadata +pub struct EnvironmentConfigResponse { + pub config: EnvironmentConfig, + pub name: String, +} + +/// Fetch environment config from Railway API +pub async fn fetch_environment_config( + client: &Client, + configs: &Configs, + environment_id: &str, + decrypt_variables: bool, +) -> Result { + let vars = queries::get_environment_config::Variables { + id: environment_id.to_string(), + decrypt_variables: Some(decrypt_variables), + }; + + let data = + post_graphql::(client, configs.get_backboard(), vars) + .await?; + + let config: EnvironmentConfig = serde_json::from_value(data.environment.config) + .context("Failed to parse environment config")?; + + Ok(EnvironmentConfigResponse { + config, + name: data.environment.name, + }) +} diff --git a/src/controllers/config/mod.rs b/src/controllers/config/mod.rs new file mode 100644 index 0000000..8a9da86 --- /dev/null +++ b/src/controllers/config/mod.rs @@ -0,0 +1,3 @@ +pub mod environment; + +pub use environment::*; diff --git a/src/controllers/develop/code_runner.rs b/src/controllers/develop/code_runner.rs new file mode 100644 index 0000000..48d8ddc --- /dev/null +++ b/src/controllers/develop/code_runner.rs @@ -0,0 +1,177 @@ +use std::{collections::BTreeMap, path::PathBuf, process::Stdio}; + +use anyhow::{Context, Result}; +use colored::{Color, Colorize}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child, Command}, + sync::mpsc, +}; + +const COLORS: &[Color] = &[ + Color::Cyan, + Color::Green, + Color::Yellow, + Color::Magenta, + Color::Blue, + Color::Red, +]; + +#[derive(Debug)] +pub struct LogLine { + pub service_name: String, + pub message: String, + pub is_stderr: bool, + pub color: Color, +} + +struct ManagedProcess { + #[allow(dead_code)] + service_name: String, + child: Child, + #[allow(dead_code)] + color: Color, +} + +pub struct ProcessManager { + processes: Vec, +} + +impl ProcessManager { + pub fn new() -> Self { + Self { + processes: Vec::new(), + } + } + + pub async fn spawn_service( + &mut self, + service_name: String, + command: &str, + working_dir: PathBuf, + env_vars: BTreeMap, + log_tx: mpsc::Sender, + ) -> Result<()> { + let color = COLORS[self.processes.len() % COLORS.len()]; + + #[cfg(unix)] + let mut child = Command::new("sh") + .args(["-c", command]) + .current_dir(&working_dir) + .envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Failed to spawn '{}'", command))?; + + #[cfg(windows)] + let mut child = Command::new("cmd") + .args(["/C", command]) + .current_dir(&working_dir) + .envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Failed to spawn '{}'", command))?; + + let cmd_log = LogLine { + service_name: service_name.clone(), + message: format!("$ {}", command), + is_stderr: false, + color, + }; + let _ = log_tx.send(cmd_log).await; + + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let name = service_name.clone(); + let tx = log_tx.clone(); + tokio::spawn(async move { + stream_output(stdout, name, color, false, tx).await; + }); + + let name2 = service_name.clone(); + tokio::spawn(async move { + stream_output(stderr, name2, color, true, log_tx).await; + }); + + self.processes.push(ManagedProcess { + service_name, + child, + color, + }); + + Ok(()) + } + + pub async fn shutdown(&mut self) { + #[cfg(unix)] + { + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + for proc in &self.processes { + if let Some(pid) = proc.child.id() { + let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM); + } + } + } + + #[cfg(windows)] + { + for proc in &mut self.processes { + let _ = proc.child.kill().await; + } + } + + for proc in &mut self.processes { + let _ = + tokio::time::timeout(std::time::Duration::from_secs(5), proc.child.wait()).await; + } + + for proc in &mut self.processes { + let _ = proc.child.kill().await; + } + + self.processes.clear(); + } +} + +impl Default for ProcessManager { + fn default() -> Self { + Self::new() + } +} + +async fn stream_output( + reader: R, + service_name: String, + color: Color, + is_stderr: bool, + tx: mpsc::Sender, +) { + let mut lines = BufReader::new(reader).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let log = LogLine { + service_name: service_name.clone(), + message: line, + is_stderr, + color, + }; + if tx.send(log).await.is_err() { + break; + } + } +} + +pub fn print_log_line(log: &LogLine) { + let prefix = format!("[{}]", log.service_name).color(log.color); + if log.is_stderr { + eprintln!("{} {}", prefix, log.message); + } else { + println!("{} {}", prefix, log.message); + } +} diff --git a/src/controllers/develop/compose.rs b/src/controllers/develop/compose.rs new file mode 100644 index 0000000..1519dfd --- /dev/null +++ b/src/controllers/develop/compose.rs @@ -0,0 +1,219 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; + +use super::ports::generate_port; +use crate::controllers::config::ServiceInstance; + +#[derive(Debug, Clone)] +pub enum PortType { + Http, + Tcp, +} + +#[derive(Debug, Clone)] +pub struct PortInfo { + pub internal: i64, + pub external: u16, + pub public_port: u16, + pub port_type: PortType, +} + +#[derive(Debug, Serialize)] +pub struct DockerComposeFile { + pub services: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub networks: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub volumes: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct DockerComposeVolume {} + +#[derive(Debug, Serialize)] +pub struct DockerComposeNetworks { + pub railway: DockerComposeNetwork, +} + +#[derive(Debug, Serialize)] +pub struct DockerComposeNetwork { + pub driver: String, +} + +#[derive(Debug, Serialize)] +pub struct DockerComposeService { + pub image: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub restart: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub environment: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub volumes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub networks: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub extra_hosts: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct ComposeServiceStatus { + #[serde(rename = "Service")] + pub service: String, + #[serde(rename = "State")] + pub state: String, + #[serde(rename = "Health")] + pub health: String, + #[serde(rename = "ExitCode")] + pub exit_code: i32, +} + +pub fn volume_name(environment_id: &str, volume_id: &str) -> String { + format!("railway_{}_{}", &environment_id[..8], &volume_id[..8]) +} + +pub fn build_port_infos(service_id: &str, svc: &ServiceInstance) -> Vec { + let mut port_infos = Vec::new(); + if let Some(networking) = &svc.networking { + for config in networking.service_domains.values().flatten() { + if let Some(port) = config.port { + if !port_infos.iter().any(|p: &PortInfo| p.internal == port) { + let private_port = generate_port(service_id, port); + let public_port = generate_port(service_id, port + 10000); + port_infos.push(PortInfo { + internal: port, + external: private_port, + public_port, + port_type: PortType::Http, + }); + } + } + } + for port_str in networking.tcp_proxies.keys() { + if let Ok(port) = port_str.parse::() { + if !port_infos.iter().any(|p| p.internal == port) { + let ext_port = generate_port(service_id, port); + port_infos.push(PortInfo { + internal: port, + external: ext_port, + public_port: ext_port, + port_type: PortType::Tcp, + }); + } + } + } + } + port_infos +} + +pub fn build_slug_port_mapping(service_id: &str, svc: &ServiceInstance) -> HashMap { + let mut mapping = HashMap::new(); + if let Some(networking) = &svc.networking { + for config in networking.service_domains.values().flatten() { + if let Some(port) = config.port { + mapping + .entry(port) + .or_insert_with(|| generate_port(service_id, port)); + } + } + for port_str in networking.tcp_proxies.keys() { + if let Ok(port) = port_str.parse::() { + mapping + .entry(port) + .or_insert_with(|| generate_port(service_id, port)); + } + } + } + mapping +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::controllers::config::{DomainConfig, ServiceInstance, ServiceNetworking}; + + #[test] + fn test_volume_name() { + assert_eq!( + volume_name("env-12345678-xxxx", "vol-abcdefgh-yyyy"), + "railway_env-1234_vol-abcd" + ); + } + + #[test] + fn test_build_port_infos_with_http_domain() { + let svc = ServiceInstance { + networking: Some(ServiceNetworking { + service_domains: BTreeMap::from([( + "example.up.railway.app".to_string(), + Some(DomainConfig { port: Some(8080) }), + )]), + ..Default::default() + }), + ..Default::default() + }; + let ports = build_port_infos("svc-123", &svc); + assert_eq!(ports.len(), 1); + assert_eq!(ports[0].internal, 8080); + assert!(matches!(ports[0].port_type, PortType::Http)); + } + + #[test] + fn test_build_port_infos_with_tcp_proxy() { + let svc = ServiceInstance { + networking: Some(ServiceNetworking { + tcp_proxies: BTreeMap::from([("6379".to_string(), None)]), + ..Default::default() + }), + ..Default::default() + }; + let ports = build_port_infos("redis-svc", &svc); + assert_eq!(ports.len(), 1); + assert_eq!(ports[0].internal, 6379); + assert!(matches!(ports[0].port_type, PortType::Tcp)); + } + + #[test] + fn test_build_port_infos_deduplicates() { + let svc = ServiceInstance { + networking: Some(ServiceNetworking { + service_domains: BTreeMap::from([ + ( + "a.railway.app".to_string(), + Some(DomainConfig { port: Some(3000) }), + ), + ( + "b.railway.app".to_string(), + Some(DomainConfig { port: Some(3000) }), + ), + ]), + ..Default::default() + }), + ..Default::default() + }; + let ports = build_port_infos("svc", &svc); + assert_eq!(ports.len(), 1); + } + + #[test] + fn test_build_slug_port_mapping() { + let svc = ServiceInstance { + networking: Some(ServiceNetworking { + service_domains: BTreeMap::from([( + "example.railway.app".to_string(), + Some(DomainConfig { port: Some(8080) }), + )]), + tcp_proxies: BTreeMap::from([("5432".to_string(), None)]), + ..Default::default() + }), + ..Default::default() + }; + let mapping = build_slug_port_mapping("svc-123", &svc); + assert!(mapping.contains_key(&8080)); + assert!(mapping.contains_key(&5432)); + } +} diff --git a/src/controllers/develop/https_proxy.rs b/src/controllers/develop/https_proxy.rs new file mode 100644 index 0000000..f4e9351 --- /dev/null +++ b/src/controllers/develop/https_proxy.rs @@ -0,0 +1,284 @@ +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +#[allow(dead_code)] +pub struct HttpsConfig { + pub project_slug: String, + pub base_domain: String, + pub cert_path: PathBuf, + pub key_path: PathBuf, + pub use_port_443: bool, +} + +/// Check if port 443 is available for binding. +/// On macOS Mojave+, unprivileged processes can bind to 443 on 0.0.0.0. +pub fn is_port_443_available() -> bool { + TcpListener::bind("0.0.0.0:443").is_ok() +} + +pub fn check_mkcert_installed() -> bool { + Command::new("mkcert") + .arg("-help") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub fn check_docker_compose_installed() -> bool { + Command::new("docker") + .args(["compose", "version"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +pub fn ensure_mkcert_ca() -> Result<()> { + let output = Command::new("mkcert") + .arg("-install") + .output() + .context("Failed to run mkcert -install")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("mkcert -install failed: {}", stderr); + } + + Ok(()) +} + +/// Check if certs already exist for a project with the required type +pub fn certs_exist(output_dir: &Path, use_port_443: bool) -> bool { + let cert_path = output_dir.join("cert.pem"); + let key_path = output_dir.join("key.pem"); + let mode_file = output_dir.join("https_mode"); + + if !cert_path.exists() || !key_path.exists() { + return false; + } + + if let Ok(mode) = std::fs::read_to_string(&mode_file) { + let stored_443 = mode.trim() == "port_443"; + stored_443 == use_port_443 + } else { + // No mode file = old certs, need regeneration for port 443 + !use_port_443 + } +} + +/// Get config for existing certs without regenerating +pub fn get_existing_certs( + project_slug: &str, + output_dir: &Path, + use_port_443: bool, +) -> HttpsConfig { + let base_domain = format!("{}.railway.localhost", project_slug); + HttpsConfig { + project_slug: project_slug.to_string(), + base_domain, + cert_path: output_dir.join("cert.pem"), + key_path: output_dir.join("key.pem"), + use_port_443, + } +} + +pub fn generate_certs( + project_slug: &str, + output_dir: &Path, + use_port_443: bool, +) -> Result { + let base_domain = format!("{}.railway.localhost", project_slug); + let wildcard_domain = format!("*.{}", base_domain); + + let cert_path = output_dir.join("cert.pem"); + let key_path = output_dir.join("key.pem"); + + std::fs::create_dir_all(output_dir)?; + + let mut cmd = Command::new("mkcert"); + cmd.arg("-cert-file") + .arg(&cert_path) + .arg("-key-file") + .arg(&key_path); + + // For port 443 mode, generate wildcard cert for all service subdomains + if use_port_443 { + cmd.arg(&wildcard_domain); + } + cmd.arg(&base_domain); + + let output = cmd.output().context("Failed to run mkcert")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("mkcert failed: {}", stderr); + } + + // Save the mode so we know what type of certs we have + let mode_file = output_dir.join("https_mode"); + let mode = if use_port_443 { "port_443" } else { "fallback" }; + std::fs::write(&mode_file, mode)?; + + Ok(HttpsConfig { + project_slug: project_slug.to_string(), + base_domain, + cert_path, + key_path, + use_port_443, + }) +} + +pub struct ServicePort { + pub slug: String, + pub internal_port: i64, + pub external_port: u16, + pub is_http: bool, + pub is_code_service: bool, +} + +pub fn generate_caddyfile(services: &[ServicePort], https_config: &HttpsConfig) -> String { + let mut caddyfile = String::new(); + + caddyfile.push_str("{\n"); + caddyfile.push_str(" auto_https off\n"); + caddyfile.push_str("}\n\n"); + + for svc in services.iter().filter(|s| s.is_http) { + // Port 443 mode: SNI routing with subdomains (no port in URL) + // Fallback mode: per-service ports (current behavior) + let site_address = if https_config.use_port_443 { + format!("{}.{}", svc.slug, https_config.base_domain) + } else { + format!("{}:{}", https_config.base_domain, svc.external_port) + }; + + caddyfile.push_str(&format!("{} {{\n", site_address)); + caddyfile.push_str(" tls /certs/cert.pem /certs/key.pem\n"); + + // Code services run on host network, image services run in Docker network + let upstream = if svc.is_code_service { + format!("host.docker.internal:{}", svc.internal_port) + } else { + format!("{}:{}", svc.slug, svc.internal_port) + }; + + caddyfile.push_str(&format!(" reverse_proxy {}\n", upstream)); + caddyfile.push_str("}\n\n"); + } + + caddyfile +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_generate_caddyfile_port_443_mode() { + let services = vec![ServicePort { + slug: "api".to_string(), + internal_port: 3000, + external_port: 12345, + is_http: true, + is_code_service: false, + }]; + let config = HttpsConfig { + project_slug: "myproj".to_string(), + base_domain: "myproj.railway.localhost".to_string(), + cert_path: PathBuf::from("/certs/cert.pem"), + key_path: PathBuf::from("/certs/key.pem"), + use_port_443: true, + }; + let result = generate_caddyfile(&services, &config); + assert!(result.contains("api.myproj.railway.localhost {")); + assert!(result.contains("reverse_proxy api:3000")); + } + + #[test] + fn test_generate_caddyfile_fallback_mode() { + let services = vec![ServicePort { + slug: "api".to_string(), + internal_port: 3000, + external_port: 12345, + is_http: true, + is_code_service: false, + }]; + let config = HttpsConfig { + project_slug: "myproj".to_string(), + base_domain: "myproj.railway.localhost".to_string(), + cert_path: PathBuf::from("/certs/cert.pem"), + key_path: PathBuf::from("/certs/key.pem"), + use_port_443: false, + }; + let result = generate_caddyfile(&services, &config); + assert!(result.contains("myproj.railway.localhost:12345 {")); + assert!(result.contains("reverse_proxy api:3000")); + } + + #[test] + fn test_generate_caddyfile_code_service() { + let services = vec![ServicePort { + slug: "web".to_string(), + internal_port: 8080, + external_port: 54321, + is_http: true, + is_code_service: true, + }]; + let config = HttpsConfig { + project_slug: "myproj".to_string(), + base_domain: "myproj.railway.localhost".to_string(), + cert_path: PathBuf::from("/certs/cert.pem"), + key_path: PathBuf::from("/certs/key.pem"), + use_port_443: false, + }; + let result = generate_caddyfile(&services, &config); + assert!(result.contains("reverse_proxy host.docker.internal:8080")); + } + + #[test] + fn test_generate_caddyfile_filters_non_http() { + let services = vec![ + ServicePort { + slug: "api".to_string(), + internal_port: 3000, + external_port: 12345, + is_http: true, + is_code_service: false, + }, + ServicePort { + slug: "redis".to_string(), + internal_port: 6379, + external_port: 54321, + is_http: false, + is_code_service: false, + }, + ]; + let config = HttpsConfig { + project_slug: "myproj".to_string(), + base_domain: "myproj.railway.localhost".to_string(), + cert_path: PathBuf::from("/certs/cert.pem"), + key_path: PathBuf::from("/certs/key.pem"), + use_port_443: false, + }; + let result = generate_caddyfile(&services, &config); + assert!(result.contains("api")); + assert!(!result.contains("redis")); + } + + #[test] + fn test_certs_exist_returns_false_when_missing() { + let temp = TempDir::new().unwrap(); + assert!(!certs_exist(temp.path(), false)); + } + + #[test] + fn test_certs_exist_returns_true_when_present() { + let temp = TempDir::new().unwrap(); + std::fs::write(temp.path().join("cert.pem"), "cert").unwrap(); + std::fs::write(temp.path().join("key.pem"), "key").unwrap(); + assert!(certs_exist(temp.path(), false)); + } +} diff --git a/src/controllers/develop/local_config.rs b/src/controllers/develop/local_config.rs new file mode 100644 index 0000000..305f257 --- /dev/null +++ b/src/controllers/develop/local_config.rs @@ -0,0 +1,98 @@ +use std::{collections::HashMap, fs, path::PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::ports::get_develop_dir; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct LocalDevConfig { + pub version: u32, + pub services: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CodeServiceConfig { + pub command: String, + pub directory: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +impl LocalDevConfig { + pub fn path(project_id: &str) -> PathBuf { + get_develop_dir(project_id).join("local-dev.json") + } + + pub fn load(project_id: &str) -> Result { + let path = Self::path(project_id); + if !path.exists() { + return Ok(Self::default()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display())) + } + + pub fn save(&self, project_id: &str) -> Result<()> { + let path = Self::path(project_id); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + + let tmp_path = path.with_extension("tmp"); + let content = serde_json::to_string_pretty(self)?; + fs::write(&tmp_path, content) + .with_context(|| format!("Failed to write {}", tmp_path.display()))?; + fs::rename(&tmp_path, &path).with_context(|| { + format!( + "Failed to rename {} to {}", + tmp_path.display(), + path.display() + ) + })?; + + Ok(()) + } + + pub fn get_service(&self, service_id: &str) -> Option<&CodeServiceConfig> { + self.services.get(service_id) + } + + pub fn set_service(&mut self, service_id: String, config: CodeServiceConfig) { + self.services.insert(service_id, config); + } + + pub fn remove_service(&mut self, service_id: &str) -> Option { + self.services.remove(service_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_service_operations() { + let mut config = LocalDevConfig::default(); + assert!(config.get_service("svc-123").is_none()); + + config.set_service( + "svc-123".to_string(), + CodeServiceConfig { + command: "npm start".to_string(), + directory: "/app".to_string(), + port: Some(3000), + }, + ); + + assert!(config.get_service("svc-123").is_some()); + assert_eq!(config.get_service("svc-123").unwrap().command, "npm start"); + + config.remove_service("svc-123"); + assert!(config.get_service("svc-123").is_none()); + } +} diff --git a/src/controllers/develop/mod.rs b/src/controllers/develop/mod.rs new file mode 100644 index 0000000..75652ad --- /dev/null +++ b/src/controllers/develop/mod.rs @@ -0,0 +1,17 @@ +pub mod code_runner; +pub mod compose; +pub mod https_proxy; +pub mod local_config; +pub mod output; +pub mod ports; +pub mod session; +pub mod variables; + +pub use code_runner::*; +pub use compose::*; +pub use https_proxy::*; +pub use local_config::*; +pub use output::*; +pub use ports::*; +pub use session::*; +pub use variables::*; diff --git a/src/controllers/develop/output.rs b/src/controllers/develop/output.rs new file mode 100644 index 0000000..ffdca9c --- /dev/null +++ b/src/controllers/develop/output.rs @@ -0,0 +1,9 @@ +use super::compose::PortInfo; + +pub struct ServiceSummary { + pub name: String, + pub image: String, + pub var_count: usize, + pub ports: Vec, + pub volumes: Vec, +} diff --git a/src/controllers/develop/ports.rs b/src/controllers/develop/ports.rs new file mode 100644 index 0000000..922d267 --- /dev/null +++ b/src/controllers/develop/ports.rs @@ -0,0 +1,196 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::controllers::config::EnvironmentConfig; + +/// Port range for deterministic port generation (10000-59999) +pub const PORT_RANGE_MIN: u16 = 10000; +pub const PORT_RANGE_SIZE: u16 = 50000; + +/// Port range for random port generation during configuration +pub const RANDOM_PORT_MIN: u16 = 3000; +pub const RANDOM_PORT_MAX: u16 = 9000; + +/// Converts a service name to a slug (lowercase, alphanumeric, dashes) +pub fn slugify(name: &str) -> String { + let s: String = name + .chars() + .filter_map(|c| { + if c.is_ascii_alphanumeric() { + Some(c.to_ascii_lowercase()) + } else if c == ' ' || c == '-' || c == '_' { + Some('-') + } else { + None + } + }) + .collect(); + s.trim_matches('-').to_string() +} + +/// Generates a deterministic external port from service_id and internal_port +pub fn generate_port(service_id: &str, internal_port: i64) -> u16 { + let mut hash: u32 = 5381; + for b in service_id.bytes() { + hash = hash.wrapping_mul(33).wrapping_add(b as u32); + } + hash = hash.wrapping_add(internal_port as u32); + PORT_RANGE_MIN + (hash % PORT_RANGE_SIZE as u32) as u16 +} + +/// Generates a random port for user configuration prompts +pub fn generate_random_port() -> u16 { + use rand::Rng; + rand::thread_rng().gen_range(RANDOM_PORT_MIN..RANDOM_PORT_MAX) +} + +/// Resolves a path, canonicalizing on Unix but not on Windows (to avoid UNC path prefix) +pub fn resolve_path(path: std::path::PathBuf) -> std::path::PathBuf { + #[cfg(unix)] + { + path.canonicalize().unwrap_or(path) + } + #[cfg(windows)] + { + path + } +} + +/// Returns the develop directory for a given project +pub fn get_develop_dir(project_id: &str) -> PathBuf { + dirs::home_dir() + .expect("Unable to get home directory") + .join(".railway") + .join("develop") + .join(project_id) +} + +/// Returns the path to the docker-compose.yml for a given project +pub fn get_compose_path(project_id: &str) -> PathBuf { + get_develop_dir(project_id).join("docker-compose.yml") +} + +/// Check if local develop mode is active (compose file exists) +pub fn is_local_develop_active(project_id: &str) -> bool { + get_compose_path(project_id).exists() +} + +/// Reads the HTTPS domain from the https_domain file if it exists +pub fn get_https_domain(project_id: &str) -> Option { + let domain_file = get_develop_dir(project_id).join("https_domain"); + std::fs::read_to_string(domain_file).ok() +} + +/// Reads the HTTPS mode from the https_mode file +pub fn get_https_mode(project_id: &str) -> bool { + let mode_file = get_develop_dir(project_id).join("certs").join("https_mode"); + std::fs::read_to_string(mode_file) + .map(|m| m.trim() == "port_443") + .unwrap_or(false) +} + +/// Build service_id -> private endpoint mapping. +/// Uses privateNetworkEndpoint from config when available, falls back to slugified name. +pub fn build_service_endpoints( + service_names: &HashMap, + config: &EnvironmentConfig, +) -> HashMap { + service_names + .iter() + .map(|(id, name)| { + let endpoint = config + .services + .get(id) + .and_then(|svc| svc.networking.as_ref()) + .and_then(|n| n.private_network_endpoint.clone()) + .unwrap_or_else(|| slugify(name)); + (id.clone(), endpoint) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_slugify() { + assert_eq!(slugify("My Service"), "my-service"); + assert_eq!(slugify("api-server"), "api-server"); + assert_eq!(slugify("API_SERVER"), "api-server"); + assert_eq!(slugify(" Test "), "test"); + assert_eq!(slugify("hello@world!"), "helloworld"); + } + + #[test] + fn test_generate_port_deterministic() { + let port1 = generate_port("service-123", 3000); + let port2 = generate_port("service-123", 3000); + assert_eq!(port1, port2); + } + + #[test] + fn test_generate_port_in_range() { + let port = generate_port("test-service", 8080); + assert!((PORT_RANGE_MIN..(PORT_RANGE_MIN + PORT_RANGE_SIZE)).contains(&port)); + } + + #[test] + fn test_generate_port_different_services() { + let port1 = generate_port("service-a", 3000); + let port2 = generate_port("service-b", 3000); + assert_ne!(port1, port2); + } + + #[test] + fn test_generate_port_different_internal_ports() { + let port1 = generate_port("service-a", 3000); + let port2 = generate_port("service-a", 8080); + assert_ne!(port1, port2); + } + + #[test] + fn test_build_service_endpoints() { + use crate::controllers::config::{EnvironmentConfig, ServiceInstance, ServiceNetworking}; + use std::collections::BTreeMap; + + let mut service_names = HashMap::new(); + service_names.insert("svc-1".to_string(), "My PostgreSQL".to_string()); + service_names.insert("svc-2".to_string(), "Redis Cache".to_string()); + service_names.insert("svc-3".to_string(), "api-server".to_string()); + + let mut services = BTreeMap::new(); + // svc-1: has privateNetworkEndpoint set + services.insert( + "svc-1".to_string(), + ServiceInstance { + networking: Some(ServiceNetworking { + private_network_endpoint: Some("postgres".to_string()), + ..Default::default() + }), + ..Default::default() + }, + ); + // svc-2: no privateNetworkEndpoint, should fall back to slugified name + services.insert("svc-2".to_string(), ServiceInstance::default()); + // svc-3: has networking but no privateNetworkEndpoint + services.insert( + "svc-3".to_string(), + ServiceInstance { + networking: Some(ServiceNetworking::default()), + ..Default::default() + }, + ); + + let config = EnvironmentConfig { + services, + ..Default::default() + }; + + let result = build_service_endpoints(&service_names, &config); + + assert_eq!(result.get("svc-1"), Some(&"postgres".to_string())); + assert_eq!(result.get("svc-2"), Some(&"redis-cache".to_string())); + assert_eq!(result.get("svc-3"), Some(&"api-server".to_string())); + } +} diff --git a/src/controllers/develop/session.rs b/src/controllers/develop/session.rs new file mode 100644 index 0000000..a700ed3 --- /dev/null +++ b/src/controllers/develop/session.rs @@ -0,0 +1,81 @@ +use std::fs::File; +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; +use fs2::FileExt; + +use super::ports::get_develop_dir; + +pub struct DevelopSessionLock { + _file: File, + path: PathBuf, +} + +impl DevelopSessionLock { + /// Try to acquire exclusive lock for code services in this project. + /// Returns Ok(lock) if acquired, Err if another session is running. + pub fn try_acquire(project_id: &str) -> Result { + let develop_dir = get_develop_dir(project_id); + Self::try_acquire_at(&develop_dir) + } + + /// Try to acquire lock at a specific directory (for testing) + pub fn try_acquire_at(develop_dir: &Path) -> Result { + std::fs::create_dir_all(develop_dir)?; + + let path = develop_dir.join("session.lock"); + let file = File::create(&path)?; + + match file.try_lock_exclusive() { + Ok(()) => Ok(Self { _file: file, path }), + Err(e) if e.kind() == fs2::lock_contended_error().kind() => { + bail!( + "Another develop session is already running for this project.\n\ + Stop it with Ctrl+C before starting a new one." + ) + } + Err(e) => Err(e.into()), + } + } +} + +impl Drop for DevelopSessionLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_acquire_lock() { + let temp = TempDir::new().unwrap(); + let lock = DevelopSessionLock::try_acquire_at(temp.path()); + assert!(lock.is_ok()); + } + + #[test] + fn test_concurrent_lock_fails() { + let temp = TempDir::new().unwrap(); + let _lock1 = DevelopSessionLock::try_acquire_at(temp.path()).unwrap(); + let lock2 = DevelopSessionLock::try_acquire_at(temp.path()); + match lock2 { + Ok(_) => panic!("should fail to acquire lock"), + Err(e) => assert!(e.to_string().contains("Another develop session")), + } + } + + #[test] + fn test_lock_released_on_drop() { + let temp = TempDir::new().unwrap(); + { + let _lock = DevelopSessionLock::try_acquire_at(temp.path()).unwrap(); + } + // Lock should be released after drop + let lock2 = DevelopSessionLock::try_acquire_at(temp.path()); + assert!(lock2.is_ok()); + } +} diff --git a/src/controllers/develop/variables.rs b/src/controllers/develop/variables.rs new file mode 100644 index 0000000..98f1604 --- /dev/null +++ b/src/controllers/develop/variables.rs @@ -0,0 +1,374 @@ +use std::collections::{BTreeMap, HashMap}; + +/// Mode for variable overrides - affects how domains/ports are transformed +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideMode { + /// For docker-compose services - use service slugs for inter-container communication + DockerNetwork, + /// For host commands - use localhost with external ports + HostNetwork, +} + +/// HTTPS configuration for local development +pub struct HttpsOverride<'a> { + pub domain: &'a str, + pub port: u16, + /// Service slug for subdomain-based routing (port 443 mode) + pub slug: Option, + /// Whether using port 443 mode (prettier URLs without port numbers) + pub use_port_443: bool, +} + +/// Check if a Railway variable is deprecated and should be filtered out +pub fn is_deprecated_railway_var(key: &str) -> bool { + if key == "RAILWAY_STATIC_URL" { + return true; + } + // RAILWAY_SERVICE_{name}_URL is deprecated, but RAILWAY_SERVICE_ID and RAILWAY_SERVICE_NAME are not + if key.starts_with("RAILWAY_SERVICE_") && key.ends_with("_URL") { + return true; + } + false +} + +/// Maps production public domain -> local public domain for cross-service references +pub type PublicDomainMapping = HashMap; + +/// Transform Railway variables for local development +#[allow(clippy::too_many_arguments)] +pub fn override_railway_vars( + vars: BTreeMap, + service_slug: &str, + port_mapping: &HashMap, + service_slugs: &HashMap, + slug_port_mappings: &HashMap>, + public_domain_mapping: &PublicDomainMapping, + mode: OverrideMode, + https: Option, +) -> BTreeMap { + vars.into_iter() + .filter(|(key, _)| !is_deprecated_railway_var(key)) + .map(|(key, value)| { + let new_value = match key.as_str() { + "RAILWAY_PRIVATE_DOMAIN" => match mode { + OverrideMode::DockerNetwork => service_slug.to_string(), + OverrideMode::HostNetwork => "localhost".to_string(), + }, + "RAILWAY_PUBLIC_DOMAIN" => match &https { + Some(h) if h.use_port_443 => { + // Port 443 mode: use subdomain (no port in URL) + match &h.slug { + Some(slug) => format!("{}.{}", slug, h.domain), + None => format!("{}.{}", service_slug, h.domain), + } + } + Some(h) => format!("{}:{}", h.domain, h.port), + None => "localhost".to_string(), + }, + "RAILWAY_TCP_PROXY_DOMAIN" => "localhost".to_string(), + "RAILWAY_TCP_PROXY_PORT" => port_mapping + .values() + .next() + .map(|p| p.to_string()) + .unwrap_or(value), + _ => replace_domain_refs( + &value, + service_slugs, + slug_port_mappings, + public_domain_mapping, + mode, + ), + }; + (key, new_value) + }) + .collect() +} + +fn replace_domain_refs( + value: &str, + service_slugs: &HashMap, + slug_port_mappings: &HashMap>, + public_domain_mapping: &PublicDomainMapping, + mode: OverrideMode, +) -> String { + let mut result = value.to_string(); + + for slug in service_slugs.values() { + let port_mapping = slug_port_mappings.get(slug); + + // Replace {slug}.railway.internal:{port} patterns + let railway_domain = format!("{}.railway.internal", slug); + if result.contains(&railway_domain) { + match mode { + OverrideMode::DockerNetwork => { + // For docker network, just use the slug (containers resolve by name) + result = result.replace(&railway_domain, slug); + } + OverrideMode::HostNetwork => { + // For host network, replace with localhost and map ports + if let Some(ports) = port_mapping { + result = replace_domain_with_port_mapping(&result, &railway_domain, ports); + } else { + result = result.replace(&railway_domain, "localhost"); + } + } + } + } + + // For host network mode, also replace bare {slug}:{port} patterns + // Only replace exact patterns to avoid replacing protocol schemes like redis:// + if mode == OverrideMode::HostNetwork { + if let Some(ports) = port_mapping { + for (internal, external) in ports { + let old_pattern = format!("{}:{}", slug, internal); + let new_pattern = format!("localhost:{}", external); + result = result.replace(&old_pattern, &new_pattern); + } + } + } + } + + // Replace production public domains with local equivalents + for (prod_domain, local_domain) in public_domain_mapping { + result = result.replace(prod_domain, local_domain); + } + + result +} + +/// Replace domain:port patterns with localhost:external_port +fn replace_domain_with_port_mapping( + value: &str, + domain: &str, + port_mapping: &HashMap, +) -> String { + let mut result = value.to_string(); + + for (internal, external) in port_mapping { + let old_pattern = format!("{}:{}", domain, internal); + let new_pattern = format!("localhost:{}", external); + result = result.replace(&old_pattern, &new_pattern); + } + + // Replace any remaining bare domain references with localhost + result = result.replace(domain, "localhost"); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_deprecated_railway_var() { + assert!(is_deprecated_railway_var("RAILWAY_STATIC_URL")); + assert!(is_deprecated_railway_var("RAILWAY_SERVICE_API_URL")); + assert!(!is_deprecated_railway_var("RAILWAY_SERVICE_ID")); + assert!(!is_deprecated_railway_var("RAILWAY_SERVICE_NAME")); + assert!(!is_deprecated_railway_var("DATABASE_URL")); + } + + #[test] + fn test_override_private_domain_docker() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PRIVATE_DOMAIN".to_string(), + "old.value".to_string(), + ); + + let result = override_railway_vars( + vars, + "my-service", + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + OverrideMode::DockerNetwork, + None, + ); + + assert_eq!( + result.get("RAILWAY_PRIVATE_DOMAIN"), + Some(&"my-service".to_string()) + ); + } + + #[test] + fn test_override_private_domain_host() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PRIVATE_DOMAIN".to_string(), + "old.value".to_string(), + ); + + let result = override_railway_vars( + vars, + "my-service", + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + OverrideMode::HostNetwork, + None, + ); + + assert_eq!( + result.get("RAILWAY_PRIVATE_DOMAIN"), + Some(&"localhost".to_string()) + ); + } + + #[test] + fn test_override_public_domain_with_https_port_443() { + let mut vars = BTreeMap::new(); + vars.insert("RAILWAY_PUBLIC_DOMAIN".to_string(), "old.value".to_string()); + + let https = HttpsOverride { + domain: "myproject.localhost", + port: 443, + slug: Some("api".to_string()), + use_port_443: true, + }; + + let result = override_railway_vars( + vars, + "api", + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + OverrideMode::HostNetwork, + Some(https), + ); + + assert_eq!( + result.get("RAILWAY_PUBLIC_DOMAIN"), + Some(&"api.myproject.localhost".to_string()) + ); + } + + #[test] + fn test_filter_deprecated_vars() { + let mut vars = BTreeMap::new(); + vars.insert("RAILWAY_STATIC_URL".to_string(), "value".to_string()); + vars.insert("RAILWAY_SERVICE_API_URL".to_string(), "value".to_string()); + vars.insert("DATABASE_URL".to_string(), "postgres://...".to_string()); + + let result = override_railway_vars( + vars, + "service", + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + OverrideMode::HostNetwork, + None, + ); + + assert!(!result.contains_key("RAILWAY_STATIC_URL")); + assert!(!result.contains_key("RAILWAY_SERVICE_API_URL")); + assert!(result.contains_key("DATABASE_URL")); + } + + #[test] + fn test_replace_cross_service_domains() { + let mut vars = BTreeMap::new(); + // Private domain reference + vars.insert( + "REDIS_URL".to_string(), + "redis://redis.railway.internal:6379".to_string(), + ); + // Public domain references (railway + custom) + vars.insert( + "API_URL".to_string(), + "https://api-prod.up.railway.app/v1".to_string(), + ); + vars.insert( + "CUSTOM_URL".to_string(), + "https://api.mycompany.io/graphql".to_string(), + ); + // Multiple domains in one var + vars.insert( + "COMBINED".to_string(), + "api=https://api-prod.up.railway.app,custom=https://api.mycompany.io".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-redis".to_string(), "redis".to_string()); + + let mut slug_port_mappings = HashMap::new(); + let mut redis_ports = HashMap::new(); + redis_ports.insert(6379i64, 16379u16); + slug_port_mappings.insert("redis".to_string(), redis_ports); + + let mut public_domain_mapping = HashMap::new(); + public_domain_mapping.insert( + "api-prod.up.railway.app".to_string(), + "api.local.railway.localhost".to_string(), + ); + public_domain_mapping.insert( + "api.mycompany.io".to_string(), + "custom.local.railway.localhost".to_string(), + ); + + let result = override_railway_vars( + vars, + "my-service", + &HashMap::new(), + &service_slugs, + &slug_port_mappings, + &public_domain_mapping, + OverrideMode::HostNetwork, + None, + ); + + assert_eq!( + result.get("REDIS_URL"), + Some(&"redis://localhost:16379".to_string()) + ); + assert_eq!( + result.get("API_URL"), + Some(&"https://api.local.railway.localhost/v1".to_string()) + ); + assert_eq!( + result.get("CUSTOM_URL"), + Some(&"https://custom.local.railway.localhost/graphql".to_string()) + ); + assert_eq!( + result.get("COMBINED"), + Some( + &"api=https://api.local.railway.localhost,custom=https://custom.local.railway.localhost" + .to_string() + ) + ); + } + + #[test] + fn test_private_domain_docker_mode() { + let mut vars = BTreeMap::new(); + vars.insert( + "REDIS_URL".to_string(), + "redis://redis.railway.internal:6379".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-redis".to_string(), "redis".to_string()); + + let result = override_railway_vars( + vars, + "my-service", + &HashMap::new(), + &service_slugs, + &HashMap::new(), + &HashMap::new(), + OverrideMode::DockerNetwork, + None, + ); + + assert_eq!( + result.get("REDIS_URL"), + Some(&"redis://redis:6379".to_string()) + ); + } +} diff --git a/src/controllers/local_override.rs b/src/controllers/local_override.rs new file mode 100644 index 0000000..582b63c --- /dev/null +++ b/src/controllers/local_override.rs @@ -0,0 +1,333 @@ +use std::collections::{BTreeMap, HashMap}; + +use anyhow::Result; + +use crate::{ + config::Configs, + controllers::{ + config::{ServiceInstance, fetch_environment_config}, + develop::{ + HttpsOverride, LocalDevConfig, OverrideMode, PublicDomainMapping, + build_service_endpoints, generate_port, get_https_domain, get_https_mode, + override_railway_vars, + }, + }, + gql::queries::project::ProjectProject, +}; + +pub use crate::controllers::develop::ports::is_local_develop_active; + +/// Context for applying local variable overrides +pub struct LocalOverrideContext { + /// service_id -> service slug + pub service_slugs: HashMap, + /// service_id -> (internal_port -> external_port) + pub port_mappings: HashMap>, + /// slug -> (internal_port -> external_port) for value substitution + pub slug_port_mappings: HashMap>, + /// HTTPS domain for pretty URLs (e.g., "myproject.railway.localhost") + pub https_domain: Option, + /// Whether using port 443 mode (prettier URLs without port numbers) + pub use_port_443: bool, +} + +/// Build context from environment config (fetches from API) +pub async fn build_local_override_context( + client: &reqwest::Client, + configs: &Configs, + project: &ProjectProject, + environment_id: &str, +) -> Result { + build_local_override_context_with_config(client, configs, project, environment_id, None).await +} + +/// Build context from environment config with optional LocalDevConfig for code services +pub async fn build_local_override_context_with_config( + client: &reqwest::Client, + configs: &Configs, + project: &ProjectProject, + environment_id: &str, + local_dev_config: Option<&LocalDevConfig>, +) -> Result { + let env_response = fetch_environment_config(client, configs, environment_id, false).await?; + let config = env_response.config; + + let service_names: HashMap = project + .services + .edges + .iter() + .map(|e| (e.node.id.clone(), e.node.name.clone())) + .collect(); + + let service_slugs = build_service_endpoints(&service_names, &config); + + let mut port_mappings = HashMap::new(); + let mut slug_port_mappings = HashMap::new(); + + for (service_id, svc) in config.services.iter() { + if svc.is_image_based() { + let mapping = build_port_mapping(service_id, svc); + if let Some(slug) = service_slugs.get(service_id) { + slug_port_mappings.insert(slug.clone(), mapping.clone()); + } + port_mappings.insert(service_id.clone(), mapping); + } + } + + if let Some(dev_config) = local_dev_config { + for (service_id, svc) in config.services.iter() { + if svc.is_code_based() { + if let Some(code_config) = dev_config.services.get(service_id) { + let port = code_config + .port + .map(|p| p as i64) + .or_else(|| svc.get_ports().first().copied()) + .unwrap_or(3000); + + let external_port = code_config + .port + .unwrap_or_else(|| generate_port(service_id, port)); + + let mut mapping = HashMap::new(); + // For code services, map all internal ports to the configured external port + for internal in svc.get_ports() { + mapping.insert(internal, external_port); + } + // Also include the configured port itself + mapping.insert(port, external_port); + + if let Some(slug) = service_slugs.get(service_id) { + slug_port_mappings.insert(slug.clone(), mapping.clone()); + } + port_mappings.insert(service_id.clone(), mapping); + } + } + } + } + + let https_domain = get_https_domain(environment_id); + let use_port_443 = get_https_mode(environment_id); + + Ok(LocalOverrideContext { + service_slugs, + port_mappings, + slug_port_mappings, + https_domain, + use_port_443, + }) +} + +fn build_port_mapping(service_id: &str, svc: &ServiceInstance) -> HashMap { + let mut mapping = HashMap::new(); + if let Some(networking) = &svc.networking { + for config in networking.service_domains.values().flatten() { + if let Some(port) = config.port { + mapping + .entry(port) + .or_insert_with(|| generate_port(service_id, port)); + } + } + for port_str in networking.tcp_proxies.keys() { + if let Ok(port) = port_str.parse::() { + mapping + .entry(port) + .or_insert_with(|| generate_port(service_id, port)); + } + } + } + mapping +} + +/// Apply local overrides to variables for the run command (host network mode) +pub fn apply_local_overrides( + vars: BTreeMap, + service_id: &str, + ctx: &LocalOverrideContext, +) -> BTreeMap { + let service_slug = ctx + .service_slugs + .get(service_id) + .cloned() + .unwrap_or_default(); + let port_mapping = ctx + .port_mappings + .get(service_id) + .cloned() + .unwrap_or_default(); + + // Get HTTPS override for this service + let https = ctx.https_domain.as_ref().map(|domain| { + let port = port_mapping + .values() + .next() + .copied() + .unwrap_or_else(|| generate_port(service_id, 3000)); + HttpsOverride { + domain, + port, + slug: Some(service_slug.clone()), + use_port_443: ctx.use_port_443, + } + }); + + // TODO: For full cross-service public domain replacement in `run` command, + // we'd need to fetch all service variables upfront and build the mapping. + // For now, use empty mapping - cross-service refs won't be replaced. + let public_domain_mapping: PublicDomainMapping = HashMap::new(); + + override_railway_vars( + vars, + &service_slug, + &port_mapping, + &ctx.service_slugs, + &ctx.slug_port_mappings, + &public_domain_mapping, + OverrideMode::HostNetwork, + https, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_context( + https_domain: Option<&str>, + use_port_443: bool, + service_slugs: HashMap, + port_mappings: HashMap>, + ) -> LocalOverrideContext { + LocalOverrideContext { + service_slugs, + port_mappings, + slug_port_mappings: HashMap::new(), + https_domain: https_domain.map(String::from), + use_port_443, + } + } + + #[test] + fn test_apply_local_overrides_public_domain_with_port_mapping() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PUBLIC_DOMAIN".to_string(), + "old.railway.app".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-1".to_string(), "api".to_string()); + + let mut port_mappings = HashMap::new(); + let mut mapping = HashMap::new(); + mapping.insert(3000, 12345u16); + port_mappings.insert("svc-1".to_string(), mapping); + + let ctx = make_context( + Some("myproject.localhost"), + true, + service_slugs, + port_mappings, + ); + + let result = apply_local_overrides(vars, "svc-1", &ctx); + assert_eq!( + result.get("RAILWAY_PUBLIC_DOMAIN"), + Some(&"api.myproject.localhost".to_string()) + ); + } + + #[test] + fn test_apply_local_overrides_public_domain_without_port_mapping() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PUBLIC_DOMAIN".to_string(), + "old.railway.app".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-1".to_string(), "api".to_string()); + + let ctx = make_context( + Some("myproject.localhost"), + true, + service_slugs, + HashMap::new(), + ); + + let result = apply_local_overrides(vars, "svc-1", &ctx); + assert_eq!( + result.get("RAILWAY_PUBLIC_DOMAIN"), + Some(&"api.myproject.localhost".to_string()) + ); + } + + #[test] + fn test_apply_local_overrides_public_domain_port_mode() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PUBLIC_DOMAIN".to_string(), + "old.railway.app".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-1".to_string(), "api".to_string()); + + let mut port_mappings = HashMap::new(); + let mut mapping = HashMap::new(); + mapping.insert(3000, 12345u16); + port_mappings.insert("svc-1".to_string(), mapping); + + let ctx = make_context( + Some("myproject.localhost"), + false, + service_slugs, + port_mappings, + ); + + let result = apply_local_overrides(vars, "svc-1", &ctx); + assert_eq!( + result.get("RAILWAY_PUBLIC_DOMAIN"), + Some(&"myproject.localhost:12345".to_string()) + ); + } + + #[test] + fn test_apply_local_overrides_no_https_domain() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PUBLIC_DOMAIN".to_string(), + "old.railway.app".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-1".to_string(), "api".to_string()); + + let ctx = make_context(None, false, service_slugs, HashMap::new()); + + let result = apply_local_overrides(vars, "svc-1", &ctx); + assert_eq!( + result.get("RAILWAY_PUBLIC_DOMAIN"), + Some(&"localhost".to_string()) + ); + } + + #[test] + fn test_apply_local_overrides_private_domain() { + let mut vars = BTreeMap::new(); + vars.insert( + "RAILWAY_PRIVATE_DOMAIN".to_string(), + "old.railway.internal".to_string(), + ); + + let mut service_slugs = HashMap::new(); + service_slugs.insert("svc-1".to_string(), "api".to_string()); + + let ctx = make_context(None, false, service_slugs, HashMap::new()); + + let result = apply_local_overrides(vars, "svc-1", &ctx); + assert_eq!( + result.get("RAILWAY_PRIVATE_DOMAIN"), + Some(&"localhost".to_string()) + ); + } +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 36f35da..941a3ab 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,6 +1,9 @@ +pub mod config; pub mod database; pub mod deployment; +pub mod develop; pub mod environment; +pub mod local_override; pub mod project; pub mod service; pub mod terminal; diff --git a/src/gql/queries/mod.rs b/src/gql/queries/mod.rs index fc110b8..ab24e8d 100644 --- a/src/gql/queries/mod.rs +++ b/src/gql/queries/mod.rs @@ -107,6 +107,7 @@ pub struct Domains; pub struct ProjectToken; pub type SerializedTemplateConfig = serde_json::Value; +pub type EnvironmentConfig = serde_json::Value; #[derive(GraphQLQuery)] #[graphql( @@ -156,6 +157,14 @@ pub struct LatestFunctionVersion; )] pub struct EnvironmentStagedChanges; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "src/gql/schema.json", + query_path = "src/gql/queries/strings/EnvironmentConfig.graphql", + response_derives = "Debug, Serialize, Clone" +)] +pub struct GetEnvironmentConfig; + type SubscriptionDeploymentStatus = super::subscriptions::deployment::DeploymentStatus; impl From for SubscriptionDeploymentStatus { fn from(value: project::DeploymentStatus) -> Self { diff --git a/src/gql/queries/strings/EnvironmentConfig.graphql b/src/gql/queries/strings/EnvironmentConfig.graphql new file mode 100644 index 0000000..732af58 --- /dev/null +++ b/src/gql/queries/strings/EnvironmentConfig.graphql @@ -0,0 +1,7 @@ +query GetEnvironmentConfig($id: String!, $decryptVariables: Boolean) { + environment(id: $id) { + id + name + config(decryptVariables: $decryptVariables) + } +} diff --git a/src/gql/schema.json b/src/gql/schema.json index 792d810..d427dd5 100644 --- a/src/gql/schema.json +++ b/src/gql/schema.json @@ -146,7 +146,7 @@ "deprecationReason": null, "description": null, "isDeprecated": false, - "name": "BUCKETS" + "name": "AUDIT_LOGS" }, { "deprecationReason": null, @@ -158,13 +158,7 @@ "deprecationReason": null, "description": null, "isDeprecated": false, - "name": "ENVIRONMENT_RESTRICTIONS" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "GLOBAL_BUCKET_REGION" + "name": "CONVERSATIONAL_UI" }, { "deprecationReason": null, @@ -195,12 +189,6 @@ "description": null, "isDeprecated": false, "name": "RAW_SQL_QUERIES" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "UNIFIED_TEMPLATE_EDITOR" } ], "fields": null, @@ -219,24 +207,30 @@ "isDeprecated": false, "name": "ALLOW_REPLICA_METRICS" }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "BUCKETS" - }, { "deprecationReason": null, "description": null, "isDeprecated": false, "name": "BUILDER_V3_ROLLOUT_EXISTING_SERVICES" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BUILDER_V3_ROLLOUT_EXISTING_SERVICES_PRO" + }, { "deprecationReason": null, "description": null, "isDeprecated": false, "name": "BUILDER_V3_ROLLOUT_NEW_SERVICES" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BUILDER_V3_ROLLOUT_NEW_SERVICES_PRO" + }, { "deprecationReason": null, "description": null, @@ -261,6 +255,18 @@ "isDeprecated": false, "name": "MONOREPO_SUPPORT" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "SCYLLADB_ROUTING_ENABLED" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "SPLIT_USAGE_QUERIES" + }, { "deprecationReason": null, "description": null, @@ -912,6 +918,41 @@ "name": "ApiToken", "possibleTypes": null }, + { + "description": "Information about the current API token and its accessible workspaces.", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "Workspaces this subject can operate on via this token or session.", + "isDeprecated": false, + "name": "workspaces", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiTokenWorkspace", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "ApiTokenContext", + "possibleTypes": null + }, { "description": null, "enumValues": null, @@ -990,6 +1031,49 @@ "name": "ApiTokenRateLimit", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "ApiTokenWorkspace", + "possibleTypes": null + }, { "description": null, "enumValues": null, @@ -1069,79 +1153,6 @@ "name": "AppliedByMember", "possibleTypes": null }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "actor", - "type": { - "kind": "OBJECT", - "name": "User", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "banReason", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "createdAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "BanReasonHistory", - "possibleTypes": null - }, { "description": null, "enumValues": null, @@ -1438,6 +1449,95 @@ "name": "CertificateStatus", "possibleTypes": null }, + { + "description": null, + "enumValues": [ + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_CLEANING_UP" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_COMPLETE" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_CREATING_ORDER" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_DOWNLOADING_CERTIFICATE" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_FAILED" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_FETCHING_AUTHORIZATIONS" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_FINALIZING_ORDER" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_GENERATING_KEYS" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_INITIATING_CHALLENGES" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_POLLING_AUTHORIZATIONS" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_PRESENTING_CHALLENGES" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "CERTIFICATE_STATUS_TYPE_DETAILED_UNSPECIFIED" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "UNRECOGNIZED" + } + ], + "fields": null, + "inputFields": null, + "interfaces": null, + "kind": "ENUM", + "name": "CertificateStatusDetailed", + "possibleTypes": null + }, { "description": null, "enumValues": null, @@ -2180,6 +2280,18 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "certificateStatusDetailed", + "type": { + "kind": "ENUM", + "name": "CertificateStatusDetailed", + "ofType": null + } + }, { "args": [], "deprecationReason": null, @@ -5556,6 +5668,18 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "certificateStatusDetailed", + "type": { + "kind": "ENUM", + "name": "CertificateStatusDetailed", + "ofType": null + } + }, { "args": [], "deprecationReason": null, @@ -5770,6 +5894,33 @@ } } }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "decryptVariables", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "config", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "EnvironmentConfig", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -6213,7 +6364,7 @@ "possibleTypes": null }, { - "description": null, + "description": "\nEnvironmentConfig is a custom scalar type that represents the serializedConfig for an environment.\nJSON Schema: https://backboard.railway.com/schema/environment.schema.json\n", "enumValues": null, "fields": null, "inputFields": null, @@ -6527,6 +6678,18 @@ "ofType": null } }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "latestSuccessfulGitHubDeploymentId", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, { "args": [], "deprecationReason": null, @@ -6698,6 +6861,33 @@ "ofType": null } }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "decryptVariables", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "patch", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "EnvironmentConfig", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -9912,6 +10102,22 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "start", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -11498,6 +11704,57 @@ } } }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "commitMessage", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "environmentId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": "Skip deploys for services affected by this patch.", + "name": "skipDeploys", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": "Commits the staged changes for a single environment.", + "isDeprecated": false, + "name": "environmentPatchCommitStaged", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, { "args": [ { @@ -11543,6 +11800,51 @@ } } }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "environmentId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": null, + "name": "input", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "EnvironmentConfig", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Sets the staged patch for a single environment.", + "isDeprecated": false, + "name": "environmentStageChanges", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "EnvironmentPatch", + "ofType": null + } + } + }, { "args": [ { @@ -15956,11 +16258,6 @@ "name": "ApiToken", "ofType": null }, - { - "kind": "OBJECT", - "name": "BanReasonHistory", - "ofType": null - }, { "kind": "OBJECT", "name": "Container", @@ -16111,16 +16408,6 @@ "name": "ReferralInfo", "ofType": null }, - { - "kind": "OBJECT", - "name": "RefundRequest", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "ReissuedInvoice", - "ofType": null - }, { "kind": "OBJECT", "name": "Service", @@ -16156,11 +16443,6 @@ "name": "TemplateService", "ofType": null }, - { - "kind": "OBJECT", - "name": "UsageAnomaly", - "ofType": null - }, { "kind": "OBJECT", "name": "UsageLimit", @@ -16171,11 +16453,6 @@ "name": "User", "ofType": null }, - { - "kind": "OBJECT", - "name": "UserGithubRepo", - "ofType": null - }, { "kind": "OBJECT", "name": "Variable", @@ -16196,16 +16473,6 @@ "name": "VolumeInstanceBackupSchedule", "ofType": null }, - { - "kind": "OBJECT", - "name": "Withdrawal", - "ofType": null - }, - { - "kind": "OBJECT", - "name": "WithdrawalAccount", - "ofType": null - }, { "kind": "OBJECT", "name": "Workspace", @@ -18345,24 +18612,30 @@ "isDeprecated": false, "name": "ALLOW_REPLICA_METRICS" }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "BUCKETS" - }, { "deprecationReason": null, "description": null, "isDeprecated": false, "name": "BUILDER_V3_ROLLOUT_EXISTING_SERVICES" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BUILDER_V3_ROLLOUT_EXISTING_SERVICES_PRO" + }, { "deprecationReason": null, "description": null, "isDeprecated": false, "name": "BUILDER_V3_ROLLOUT_NEW_SERVICES" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "BUILDER_V3_ROLLOUT_NEW_SERVICES_PRO" + }, { "deprecationReason": null, "description": null, @@ -18387,6 +18660,18 @@ "isDeprecated": false, "name": "MONOREPO_SUPPORT" }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "SCYLLADB_ROUTING_ENABLED" + }, + { + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "SPLIT_USAGE_QUERIES" + }, { "deprecationReason": null, "description": null, @@ -19992,6 +20277,63 @@ } } }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "after", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "before", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "first", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + { + "defaultValue": null, + "description": null, + "name": "last", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "buckets", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectBucketsConnection", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -20713,6 +21055,84 @@ "name": "Project", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "edges", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectBucketsConnectionEdge", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "pageInfo", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "ProjectBucketsConnection", + "possibleTypes": null + }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "cursor", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "ProjectBucketsConnectionEdge", + "possibleTypes": null + }, { "description": null, "enumValues": null, @@ -23452,6 +23872,22 @@ } } }, + { + "args": [], + "deprecationReason": null, + "description": "Introspect the current API token and its accessible workspaces.", + "isDeprecated": false, + "name": "apiToken", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiTokenContext", + "ofType": null + } + } + }, { "args": [ { @@ -25690,76 +26126,6 @@ } } }, - { - "args": [ - { - "defaultValue": null, - "description": null, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - } - ], - "deprecationReason": null, - "description": "", - "isDeprecated": false, - "name": "node", - "type": { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - }, - { - "args": [ - { - "defaultValue": null, - "description": null, - "name": "ids", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - } - } - } - ], - "deprecationReason": null, - "description": "", - "isDeprecated": false, - "name": "nodes", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - } - } - }, { "args": [ { @@ -30678,168 +31044,6 @@ "name": "ReferralUser", "possibleTypes": null }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "amount", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Int", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "decision", - "type": { - "kind": "ENUM", - "name": "RefundRequestDecisionEnum", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "invoiceId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "plainThreadId", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "reason", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "userId", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "workspace", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Workspace", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "RefundRequest", - "possibleTypes": null - }, - { - "description": "Possible decisions for a RefundRequest", - "enumValues": [ - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "AUTO_REFUNDED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "AUTO_REJECTED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "MANUALLY_REFUNDED" - } - ], - "fields": null, - "inputFields": null, - "interfaces": null, - "kind": "ENUM", - "name": "RefundRequestDecisionEnum", - "possibleTypes": null - }, { "description": null, "enumValues": null, @@ -31125,99 +31329,6 @@ "name": "RegistryCredentialsInput", "possibleTypes": null }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "originalInvoiceId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "reissuedInvoiceId", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "workspace", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Workspace", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "workspaceId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "ReissuedInvoice", - "possibleTypes": null - }, { "description": null, "enumValues": [ @@ -31440,7 +31551,7 @@ "possibleTypes": null }, { - "description": null, + "description": "\nSerializedTemplateConfig is a custom scalar type that represents the serializedConfig for a template.\nJSON Schema: https://backboard.railway.com/schema/template.schema.json\n", "enumValues": null, "fields": null, "inputFields": null, @@ -37481,165 +37592,6 @@ "name": "Upload", "possibleTypes": null }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "actedOn", - "type": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "action", - "type": { - "kind": "ENUM", - "name": "UsageAnomalyAction", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "actorId", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "flaggedAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "flaggedFor", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "UsageAnomalyFlagReason", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "UsageAnomaly", - "possibleTypes": null - }, - { - "description": "Possible actions for a UsageAnomaly.", - "enumValues": [ - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "ALLOWED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "AUTOBANNED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "BANNED" - } - ], - "fields": null, - "inputFields": null, - "interfaces": null, - "kind": "ENUM", - "name": "UsageAnomalyAction", - "possibleTypes": null - }, - { - "description": "Possible flag reasons for a UsageAnomaly.", - "enumValues": [ - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "HIGH_CPU_USAGE" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "HIGH_DISK_USAGE" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "HIGH_NETWORK_USAGE" - } - ], - "fields": null, - "inputFields": null, - "interfaces": null, - "kind": "ENUM", - "name": "UsageAnomalyFlagReason", - "possibleTypes": null - }, { "description": null, "enumValues": null, @@ -38429,191 +38381,6 @@ "name": "UserFlagsSetInput", "possibleTypes": null }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "createdAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "defaultBranch", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "description", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "fullName", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "installationId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "isPrivate", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "lastPushedAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "name", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "ownerAvatarUrl", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "updatedAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "UserGithubRepo", - "possibleTypes": null - }, { "description": null, "enumValues": null, @@ -41387,311 +41154,6 @@ "name": "VolumeVolumeInstancesConnectionEdge", "possibleTypes": null }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "amount", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Float", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "createdAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "customerId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "status", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "WithdrawalStatusType", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "updatedAt", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "DateTime", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "withdrawalAccount", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "WithdrawalAccount", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "withdrawalAccountId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "Withdrawal", - "possibleTypes": null - }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "customerId", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "id", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "ID", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "platform", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "ENUM", - "name": "WithdrawalPlatformTypes", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "platformDetails", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "stripeConnectInfo", - "type": { - "kind": "OBJECT", - "name": "WithdrawalAccountStripeConnectInfo", - "ofType": null - } - } - ], - "inputFields": null, - "interfaces": [ - { - "kind": "INTERFACE", - "name": "Node", - "ofType": null - } - ], - "kind": "OBJECT", - "name": "WithdrawalAccount", - "possibleTypes": null - }, - { - "description": null, - "enumValues": null, - "fields": [ - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "bankLast4", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "cardLast4", - "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "hasOnboarded", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - } - }, - { - "args": [], - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "needsAttention", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - } - } - ], - "inputFields": null, - "interfaces": [], - "kind": "OBJECT", - "name": "WithdrawalAccountStripeConnectInfo", - "possibleTypes": null - }, { "description": null, "enumValues": [ @@ -41727,41 +41189,6 @@ "name": "WithdrawalPlatformTypes", "possibleTypes": null }, - { - "description": null, - "enumValues": [ - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "CANCELLED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "COMPLETED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "FAILED" - }, - { - "deprecationReason": null, - "description": null, - "isDeprecated": false, - "name": "PENDING" - } - ], - "fields": null, - "inputFields": null, - "interfaces": null, - "kind": "ENUM", - "name": "WithdrawalStatusType", - "possibleTypes": null - }, { "description": null, "enumValues": null, diff --git a/src/main.rs b/src/main.rs index 1aa5534..67fbeef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ commands!( connect, deploy, deployment, + dev(develop), domain, docs, down,