From cdc7bb0619c3babf4fc861b0eab062c008e35759 Mon Sep 17 00:00:00 2001 From: Des <10514807+dizzydes@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:41:09 +0200 Subject: [PATCH] fix: auto-select service when unlinked and only one exists, list available when multiple Co-Authored-By: Claude Sonnet 4.6 --- src/commands/deployment.rs | 10 +++++--- src/commands/domain.rs | 10 ++++++-- src/commands/down.rs | 12 +++++---- src/commands/logs.rs | 9 +++---- src/commands/redeploy.rs | 33 ++++++++++++++---------- src/commands/restart.rs | 46 ++++++++++++++++----------------- src/commands/scale.rs | 38 +++++++++++++++------------- src/commands/service.rs | 52 +++++++++++++++++++++++++++++++------- src/commands/shell.rs | 16 +++++++++--- src/controllers/project.rs | 43 +++++++++++++++++++++++++++++-- src/errors.rs | 3 --- 11 files changed, 184 insertions(+), 88 deletions(-) diff --git a/src/commands/deployment.rs b/src/commands/deployment.rs index ecd6339ce..13d208e4e 100644 --- a/src/commands/deployment.rs +++ b/src/commands/deployment.rs @@ -1,7 +1,9 @@ use super::*; use crate::client::post_graphql; use crate::controllers::environment::get_matched_environment; -use crate::controllers::project::{ensure_project_and_environment_exist, get_project}; +use crate::controllers::project::{ + ensure_project_and_environment_exist, get_project, select_service_fallback, +}; use crate::gql::queries::deployments::{DeploymentStatus, ResponseData, Variables}; use chrono::{DateTime, Local, Utc}; use serde::Serialize; @@ -128,9 +130,9 @@ async fn list_deployments( } else if let Some(linked_service_id) = linked_project.service { linked_service_id } else { - bail!( - "No service specified and no service linked. Use 'railway link' to link a service or specify one with the service argument." - ); + select_service_fallback(&project.services.edges, true)? + .id + .clone() }; let variables = Variables { diff --git a/src/commands/domain.rs b/src/commands/domain.rs index 77b8e51cf..f358d58f3 100644 --- a/src/commands/domain.rs +++ b/src/commands/domain.rs @@ -8,7 +8,9 @@ use serde_json::json; use crate::{ consts::TICK_STRING, - controllers::project::{ensure_project_and_environment_exist, get_project}, + controllers::project::{ + ensure_project_and_environment_exist, get_project, select_service_fallback, + }, errors::RailwayError, }; @@ -175,6 +177,10 @@ pub fn get_service<'a>( } if project.services.edges.len() == 1 { + eprintln!( + "No service linked — auto-selecting \"{}\"", + project.services.edges[0].node.name + ); return Ok(&project.services.edges[0].node); } @@ -203,7 +209,7 @@ pub fn get_service<'a>( } } - bail!(RailwayError::NoServices); + select_service_fallback(&project.services.edges, true) } pub fn creating_domain_spiner(message: Option) -> anyhow::Result { diff --git a/src/commands/down.rs b/src/commands/down.rs index 5465af900..89fa5f49c 100644 --- a/src/commands/down.rs +++ b/src/commands/down.rs @@ -1,15 +1,15 @@ use std::time::Duration; -use anyhow::bail; - use super::{ queries::{deployments::DeploymentListInput, deployments::DeploymentStatus}, *, }; use crate::{ consts::TICK_STRING, - controllers::{environment::get_matched_environment, project::get_project}, - errors::RailwayError, + controllers::{ + environment::get_matched_environment, + project::{get_project, select_service_fallback}, + }, util::prompt::prompt_confirm_with_default, }; @@ -56,7 +56,9 @@ pub async fn command(args: Args) -> Result<()> { // Otherwise if we have a linked service, use that (_, Some(linked_service)) => linked_service, // Otherwise it's a user error - _ => bail!(RailwayError::NoServiceLinked), + _ => select_service_fallback(&project.services.edges, false)? + .id + .to_owned(), }; let vars = queries::deployments::Variables { diff --git a/src/commands/logs.rs b/src/commands/logs.rs index e7507db1d..e05e52d45 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -10,7 +10,7 @@ use crate::{ stream_build_logs, stream_deploy_logs, stream_http_logs, }, environment::get_matched_environment, - project::{ensure_project_and_environment_exist, get_project}, + project::{ensure_project_and_environment_exist, get_project, select_service_fallback}, }, util::{ logs::{LogFormat, print_http_log, print_log}, @@ -313,10 +313,9 @@ pub async fn command(args: Args) -> Result<()> { .to_owned(), // Otherwise if we have a linked service, use that (_, Some(linked_service)) => linked_service, - // Otherwise it's a user error - _ => bail!( - "No service could be found. Please either link one with `railway service` or specify one via the `--service` flag." - ), + _ => select_service_fallback(&project.services.edges, true)? + .id + .to_owned(), }; // Fetch all deployments so we can find a sensible default deployment id if diff --git a/src/commands/redeploy.rs b/src/commands/redeploy.rs index 383e82713..90b30264f 100644 --- a/src/commands/redeploy.rs +++ b/src/commands/redeploy.rs @@ -4,6 +4,7 @@ use is_terminal::IsTerminal; use crate::{ controllers::project::{ ensure_project_and_environment_exist, find_service_instance, get_project, + select_service_fallback, }, errors::RailwayError, util::{progress::create_spinner_if, prompt::prompt_confirm_with_default}, @@ -38,18 +39,22 @@ pub async fn command(args: Args) -> Result<()> { let project = get_project(&client, &configs, linked_project.project.clone()).await?; let is_terminal = std::io::stdout().is_terminal(); - let service_id = args.service.or_else(|| linked_project.service.clone()).ok_or_else(|| anyhow!("No service found. Please link one via `railway link` or specify one via the `--service` flag."))?; - let service = project - .services - .edges - .iter() - .find(|s| { - s.node.id == service_id || s.node.name.to_lowercase() == service_id.to_lowercase() - }) - .ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_id)))?; + let service_node = match args.service.or_else(|| linked_project.service.clone()) { + Some(service_input) => project + .services + .edges + .iter() + .find(|s| { + s.node.id == service_input + || s.node.name.to_lowercase() == service_input.to_lowercase() + }) + .map(|s| &s.node) + .ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_input)))?, + None => select_service_fallback(&project.services.edges, false)?, + }; let service_in_env = - find_service_instance(&project, linked_project.environment_id()?, &service.node.id) + find_service_instance(&project, linked_project.environment_id()?, &service_node.id) .ok_or_else(|| { anyhow!("The service specified doesn't exist in the current environment") })?; @@ -62,7 +67,7 @@ pub async fn command(args: Args) -> Result<()> { bail!( "The latest deployment for service {} cannot be redeployed. \ This may be because it's currently building, deploying, or was removed.", - service.node.name + service_node.name ); } @@ -72,7 +77,7 @@ pub async fn command(args: Args) -> Result<()> { prompt_confirm_with_default( format!( "Redeploy the latest deployment from service {} in environment {}?", - service.node.name, + service_node.name, linked_project .environment_name .clone() @@ -95,7 +100,7 @@ pub async fn command(args: Args) -> Result<()> { !args.json, format!( "Redeploying the latest deployment from service {}...", - service.node.name + service_node.name ), ); @@ -116,7 +121,7 @@ pub async fn command(args: Args) -> Result<()> { } else if let Some(spinner) = spinner { spinner.finish_with_message(format!( "The latest deployment from service {} has been redeployed", - service.node.name.green() + service_node.name.green() )); } diff --git a/src/commands/restart.rs b/src/commands/restart.rs index a370320f3..62d4d72c2 100644 --- a/src/commands/restart.rs +++ b/src/commands/restart.rs @@ -5,6 +5,7 @@ use is_terminal::IsTerminal; use crate::{ controllers::project::{ ensure_project_and_environment_exist, find_service_instance, get_project, + select_service_fallback, }, errors::RailwayError, subscription::subscribe_graphql, @@ -41,25 +42,22 @@ pub async fn command(args: Args) -> Result<()> { let project = get_project(&client, &configs, linked_project.project.clone()).await?; let is_terminal = std::io::stdout().is_terminal(); - let service_id = args - .service - .or_else(|| linked_project.service.clone()) - .ok_or_else(|| { - anyhow!( - "No service found. Please link one via `railway link` or specify one via the `--service` flag." - ) - })?; - let service = project - .services - .edges - .iter() - .find(|s| { - s.node.id == service_id || s.node.name.to_lowercase() == service_id.to_lowercase() - }) - .ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_id)))?; + let service_node = match args.service.or_else(|| linked_project.service.clone()) { + Some(service_input) => project + .services + .edges + .iter() + .find(|s| { + s.node.id == service_input + || s.node.name.to_lowercase() == service_input.to_lowercase() + }) + .map(|s| &s.node) + .ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_input)))?, + None => select_service_fallback(&project.services.edges, false)?, + }; let service_in_env = - find_service_instance(&project, linked_project.environment_id()?, &service.node.id) + find_service_instance(&project, linked_project.environment_id()?, &service_node.id) .ok_or_else(|| { anyhow!("The service specified doesn't exist in the current environment") })?; @@ -74,7 +72,7 @@ pub async fn command(args: Args) -> Result<()> { prompt_confirm_with_default( format!( "Restart the latest deployment from service {} in environment {}?", - service.node.name, + service_node.name, linked_project .environment_name .clone() @@ -97,7 +95,7 @@ pub async fn command(args: Args) -> Result<()> { !args.json, format!( "Restarting the latest deployment from service {}...", - service.node.name + service_node.name ), ); @@ -118,7 +116,7 @@ pub async fn command(args: Args) -> Result<()> { let spinner = spinner.unwrap(); spinner.set_message(format!( "Waiting for deployment from service {} to be healthy...", - service.node.name + service_node.name )); let mut stream = @@ -144,21 +142,21 @@ pub async fn command(args: Args) -> Result<()> { DeploymentStatus::SUCCESS => { spinner.finish_with_message(format!( "The latest deployment from service {} has been restarted and is healthy", - service.node.name.green() + service_node.name.green() )); return Ok(()); } DeploymentStatus::FAILED => { spinner.finish_with_message(format!( "Deployment from service {} failed", - service.node.name.red() + service_node.name.red() )); bail!("Deployment failed"); } DeploymentStatus::CRASHED => { spinner.finish_with_message(format!( "Deployment from service {} crashed", - service.node.name.red() + service_node.name.red() )); bail!("Deployment crashed"); } @@ -169,7 +167,7 @@ pub async fn command(args: Args) -> Result<()> { spinner.finish_with_message(format!( "The latest deployment from service {} has been restarted", - service.node.name.green() + service_node.name.green() )); Ok(()) diff --git a/src/commands/scale.rs b/src/commands/scale.rs index 39a5c7cac..7f65ba859 100644 --- a/src/commands/scale.rs +++ b/src/commands/scale.rs @@ -1,7 +1,7 @@ use crate::{ controllers::{ environment::get_matched_environment, - project::find_service_instance, + project::{find_service_instance, select_service_fallback}, regions::{convert_hashmap_to_map, merge_config, prompt_for_regions}, }, util::progress::create_spinner_if, @@ -139,24 +139,28 @@ fn get_existing_config( environment: &str, ) -> Result<(Value, String)> { let environment_id = get_matched_environment(project, environment.to_string())?.id; - let service_input = match args.service.as_ref() { - Some(s) => s, - None => linked_project.service.as_ref().ok_or_else(|| { - anyhow::anyhow!("No service linked. Please either specify a service with the --service flag or link one with `railway service`") - })?, - }; - - let service = project.services.edges.iter().find(|p| { - (p.node.id == *service_input) - || (p.node.name.to_lowercase() == service_input.to_lowercase()) - }); - - let Some(service) = service else { - bail!("Service '{}' not found in project", service_input); + let service_id = match args + .service + .clone() + .or_else(|| linked_project.service.clone()) + { + Some(service_input) => project + .services + .edges + .iter() + .find(|p| { + p.node.id == service_input + || p.node.name.to_lowercase() == service_input.to_lowercase() + }) + .ok_or_else(|| anyhow::anyhow!("Service '{}' not found in project", service_input))? + .node + .id + .clone(), + None => select_service_fallback(&project.services.edges, false)? + .id + .clone(), }; - let service_id = service.node.id.clone(); - // check that service exists in that environment let instance = find_service_instance(project, &environment_id, &service_id); let service_meta = if let Some(instance) = instance { diff --git a/src/commands/service.rs b/src/commands/service.rs index f198b85d8..809ff411a 100644 --- a/src/commands/service.rs +++ b/src/commands/service.rs @@ -132,9 +132,24 @@ async fn link_command(args: LinkArgs) -> Result<()> { bail!("No services found") } else { if !std::io::stdout().is_terminal() { - bail!("Service name required in non-interactive mode. Usage: railway service "); + if services.len() == 1 { + services.into_iter().next().unwrap() + } else { + let names: Vec<&str> = services.iter().take(5).map(|s| s.0.name.as_str()).collect(); + let suffix = if services.len() > 5 { + format!(", +{} more", services.len() - 5) + } else { + String::new() + }; + bail!( + "Multiple services found. Specify one with: railway service \nAvailable: {}{}", + names.join(", "), + suffix + ) + } + } else { + prompt_options("Select a service", services)? } - prompt_options("Select a service", services)? }; configs.link_service(service.0.id.clone())?; @@ -223,17 +238,36 @@ async fn status_command(args: StatusArgs) -> Result<()> { .iter() .find(|s| s.id == service_name || s.name == service_name) .ok_or_else(|| RailwayError::ServiceNotFound(service_name.clone()))? - } else { - // Use linked service - let linked_service_id = linked_project - .service - .as_ref() - .context("No service linked. Use --service flag or --all to see all services")?; - + } else if let Some(linked_service_id) = linked_project.service.as_ref() { service_statuses .iter() .find(|s| &s.id == linked_service_id) .context("Linked service not found in this environment")? + } else { + match service_statuses.as_slice() { + [] => bail!("No services found in this environment"), + [only] => { + eprintln!("No service linked — auto-selecting \"{}\"", only.name); + &service_statuses[0] + } + _ => { + let names: Vec<&str> = service_statuses + .iter() + .take(5) + .map(|s| s.name.as_str()) + .collect(); + let suffix = if service_statuses.len() > 5 { + format!(", +{} more", service_statuses.len() - 5) + } else { + String::new() + }; + bail!( + "No service linked. Available: {}{}\nUse --service , --all, or run `railway service` to link one.", + names.join(", "), + suffix + ) + } + } }; if args.json { diff --git a/src/commands/shell.rs b/src/commands/shell.rs index 413b97fe0..00411c890 100644 --- a/src/commands/shell.rs +++ b/src/commands/shell.rs @@ -1,8 +1,9 @@ -use anyhow::bail; use std::collections::BTreeMap; use crate::{ - controllers::project::{ensure_project_and_environment_exist, get_project}, + controllers::project::{ + ensure_project_and_environment_exist, get_project, select_service_fallback, + }, controllers::variables::get_service_variables, errors::RailwayError, }; @@ -81,7 +82,16 @@ pub async fn command(args: Args) -> Result<()> { all_variables.append(&mut variables); } else { - bail!("No service linked. Please link one with `railway service`"); + let service_node = select_service_fallback(&project.services.edges, true)?; + let mut variables = get_service_variables( + &client, + &configs, + linked_project.project.clone(), + linked_project.environment_id()?.to_string(), + service_node.id.clone(), + ) + .await?; + all_variables.append(&mut variables); } let shell = std::env::var("SHELL").unwrap_or(match std::env::consts::OS { diff --git a/src/controllers/project.rs b/src/controllers/project.rs index fec458b99..9b2c8b96c 100644 --- a/src/controllers/project.rs +++ b/src/controllers/project.rs @@ -12,7 +12,7 @@ use crate::{ self, project::{ ProjectProject, ProjectProjectEnvironmentsEdgesNodeServiceInstancesEdgesNode, - ProjectProjectServicesEdgesNode, + ProjectProjectServicesEdges, ProjectProjectServicesEdgesNode, }, }, }, @@ -61,6 +61,42 @@ pub fn get_service( bail!(RailwayError::ServiceNotFound(service_name)) } +/// Selects a service when neither an explicit arg nor a linked service is available. +/// +/// - `auto_select: true` — safe for read-only commands. Auto-selects when exactly one +/// service exists and prints the choice to stderr. +/// - `auto_select: false` — for write/destructive commands. Always bails with the +/// available service names so the caller must be explicit via `--service`. +pub fn select_service_fallback( + services: &[ProjectProjectServicesEdges], + auto_select: bool, +) -> Result<&ProjectProjectServicesEdgesNode> { + match services { + [] => bail!(RailwayError::NoServices), + [only] if auto_select => { + eprintln!("No service linked — auto-selecting \"{}\"", only.node.name); + Ok(&only.node) + } + _ => { + let shown: Vec<&str> = services + .iter() + .take(5) + .map(|s| s.node.name.as_str()) + .collect(); + let suffix = if services.len() > 5 { + format!(", +{} more", services.len() - 5) + } else { + String::new() + }; + bail!( + "No service linked. Available: {}{}\nUse --service or run `railway service` to link one.", + shown.join(", "), + suffix + ) + } + } +} + pub async fn ensure_project_and_environment_exist( client: &Client, configs: &Configs, @@ -180,7 +216,10 @@ pub async fn resolve_service_context( .unwrap_or_else(|| linked_service.clone()); (linked_service, name) } - _ => bail!(RailwayError::NoServiceLinked), + _ => { + let s = select_service_fallback(services, true)?; + (s.id.clone(), s.name.clone()) + } }; Ok(ServiceContext { diff --git a/src/errors.rs b/src/errors.rs index 9805a792d..44a7da288 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -71,9 +71,6 @@ pub enum RailwayError { #[error("Project has no services.")] ProjectHasNoServices, - #[error("No service linked\nRun `railway service` to link a service")] - NoServiceLinked, - #[error("No command provided. Run with `railway run `")] NoCommandProvided,