From 6f6d12c128c040620180403fe7395f3372b1d925 Mon Sep 17 00:00:00 2001 From: Liam Cooper Date: Wed, 15 Apr 2026 22:33:05 -0700 Subject: [PATCH 1/3] add railway service list command with json output --- src/commands/service.rs | 83 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/commands/service.rs b/src/commands/service.rs index f198b85d8..1bc9e18ac 100644 --- a/src/commands/service.rs +++ b/src/commands/service.rs @@ -9,6 +9,7 @@ use crate::{ }, errors::RailwayError, util::prompt::{PromptService, prompt_options}, + workspace::{Project, workspaces}, }; use super::*; @@ -25,6 +26,10 @@ pub struct Args { #[derive(Parser)] enum Commands { + /// List services in the current project + #[clap(alias = "ls")] + List(ListArgs), + /// Link a service to the current project Link(LinkArgs), @@ -44,6 +49,21 @@ enum Commands { Scale(crate::commands::scale::Args), } +#[derive(Parser)] +struct ListArgs { + /// Project name or ID to list services for (defaults to linked project) + #[clap(short, long)] + project: Option, + + /// Environment to list services from (defaults to linked environment) + #[clap(short, long)] + environment: Option, + + /// Output in JSON format + #[clap(long)] + json: bool, +} + #[derive(Parser)] struct LinkArgs { /// The service ID/name to link @@ -92,6 +112,7 @@ pub async fn command(args: Args) -> Result<()> { } match args.command { + Some(Commands::List(list_args)) => list_services(list_args).await, Some(Commands::Link(link_args)) => link_command(link_args).await, Some(Commands::Status(status_args)) => status_command(status_args).await, Some(Commands::Logs(logs_args)) => crate::commands::logs::command(logs_args).await, @@ -106,6 +127,68 @@ pub async fn command(args: Args) -> Result<()> { } } +async fn list_services(args: ListArgs) -> Result<()> { + let configs = Configs::new()?; + let client = GQLClient::new_authorized(&configs)?; + let linked_project = configs.get_linked_project().await?; + + let project_id = if let Some(project_arg) = args.project { + let all_workspaces = workspaces().await?; + let all_projects: Vec = all_workspaces + .iter() + .flat_map(|w| { + w.projects() + .into_iter() + .filter(|p| p.deleted_at().is_none()) + }) + .collect(); + + if all_projects.is_empty() { + bail!(RailwayError::NoProjects); + } + let found = all_projects.iter().find(|p| { + p.id().to_lowercase() == project_arg.to_lowercase() + || p.name().to_lowercase() == project_arg.to_lowercase() + }); + match found { + Some(p) => p.id().to_string(), + None => bail!(RailwayError::ProjectNotFound), + } + } else { + linked_project.project.clone() + }; + + let project = get_project(&client, &configs, project_id).await?; + let env = match args.environment { + Some(s) => s, + None => linked_project.environment_id()?.to_string(), + }; + let result = get_matched_environment(&project, env)?; + let env_id = result.id; + + let service_ids_in_env = get_service_ids_in_env(&project, &env_id); + let services: Vec<_> = project + .services + .edges + .iter() + .filter(|a| service_ids_in_env.contains(&a.node.id)) + .map(|e| &e.node) + .collect(); + + if args.json { + println!("{}", serde_json::to_string_pretty(&services)?); + } else { + if services.is_empty() { + println!("No services found"); + } + for service in services { + println!("{} ({})", service.name.bold(), service.id.dimmed()) + } + } + + Ok(()) +} + async fn link_command(args: LinkArgs) -> Result<()> { let mut configs = Configs::new()?; let client = GQLClient::new_authorized(&configs)?; From 16f61b63ccfa0735e26444ad432ec8c766d0bff5 Mon Sep 17 00:00:00 2001 From: Liam Cooper Date: Fri, 17 Apr 2026 20:24:58 -0700 Subject: [PATCH 2/3] update for better handling of mix+match linked args and CLI specified projects/env --- src/commands/service.rs | 35 +++++++++++++---------- src/controllers/environment.rs | 52 +++++++++++++++++++++++++++++++++- src/errors.rs | 3 ++ src/util/prompt.rs | 14 ++++++++- 4 files changed, 87 insertions(+), 17 deletions(-) diff --git a/src/commands/service.rs b/src/commands/service.rs index 1bc9e18ac..a38033011 100644 --- a/src/commands/service.rs +++ b/src/commands/service.rs @@ -4,7 +4,7 @@ use serde::Serialize; use crate::{ controllers::{ - environment::get_matched_environment, + environment::{get_matched_environment, get_or_prompt_environment}, project::{ensure_project_and_environment_exist, get_project, get_service_ids_in_env}, }, errors::RailwayError, @@ -132,6 +132,8 @@ async fn list_services(args: ListArgs) -> Result<()> { let client = GQLClient::new_authorized(&configs)?; let linked_project = configs.get_linked_project().await?; + let project_explicitly_specified = args.project.is_some(); + let project_id = if let Some(project_arg) = args.project { let all_workspaces = workspaces().await?; let all_projects: Vec = all_workspaces @@ -146,25 +148,28 @@ async fn list_services(args: ListArgs) -> Result<()> { if all_projects.is_empty() { bail!(RailwayError::NoProjects); } - let found = all_projects.iter().find(|p| { - p.id().to_lowercase() == project_arg.to_lowercase() - || p.name().to_lowercase() == project_arg.to_lowercase() - }); - match found { - Some(p) => p.id().to_string(), - None => bail!(RailwayError::ProjectNotFound), - } + let project = all_projects + .iter() + .find(|p| { + p.id().to_lowercase() == project_arg.to_lowercase() + || p.name().to_lowercase() == project_arg.to_lowercase() + }) + .ok_or_else(|| RailwayError::ProjectNotFound)?; + project.id().to_string() } else { linked_project.project.clone() }; - let project = get_project(&client, &configs, project_id).await?; - let env = match args.environment { - Some(s) => s, - None => linked_project.environment_id()?.to_string(), + let project = get_project(&client, &configs, project_id.clone()).await?; + let linked = if project_explicitly_specified { + None + } else { + Some(linked_project) }; - let result = get_matched_environment(&project, env)?; - let env_id = result.id; + + let env_id = get_or_prompt_environment(linked, &project, args.environment) + .await? + .ok_or(RailwayError::NoEnvironments)?; let service_ids_in_env = get_service_ids_in_env(&project, &env_id); let services: Vec<_> = project diff --git a/src/controllers/environment.rs b/src/controllers/environment.rs index 8bc914e14..053c058d5 100644 --- a/src/controllers/environment.rs +++ b/src/controllers/environment.rs @@ -1,8 +1,12 @@ use crate::{ + LinkedProject, commands::queries::{RailwayProject, project::ProjectProjectEnvironmentsEdgesNode}, errors::RailwayError, + queries::project::ProjectProject, + util::prompt::{PromptEnvironment, prompt_select}, }; -use anyhow::Result; +use anyhow::{Context, Result, bail}; +use is_terminal::IsTerminal; pub fn get_matched_environment( project: &RailwayProject, @@ -17,3 +21,49 @@ pub fn get_matched_environment( Ok(environment.node.clone()) } + +pub async fn get_or_prompt_environment( + linked_project: Option, + project: &ProjectProject, + environment_arg: Option, +) -> Result> { + let environments = project.environments.edges.iter().collect::>(); + + let environment_id = if let Some(environment_arg) = environment_arg { + // If the user specified a service, use that + let environment_id = environments.iter().find(|environment| { + environment.node.name == environment_arg || environment.node.id == environment_arg + }); + if let Some(environment_id) = environment_id { + Some(environment_id.node.id.to_owned()) + } else { + bail!(RailwayError::EnvironmentNotFound(environment_arg)); + } + } else if let Some(environment) = linked_project.and_then(|lp| lp.environment) { + Some(environment) + } else { + // If the user didn't specify an environment, and we don't have a linked environment, get the first environment + + if environments.is_empty() { + // If there are no environments, backboard will generate one for us + None + } else { + // If there are multiple environments, prompt the user to select one + if std::io::stdout().is_terminal() { + let prompt_environments: Vec<_> = environments + .iter() + .map(|s| PromptEnvironment(&s.node)) + .collect(); + let service = prompt_select("Select an environment", prompt_environments) + .context("Please specify an environment via the `--environment` flag.")?; + Some(service.0.id.clone()) + } else { + bail!( + "Multiple environments found. Please specify an environment via the `--environment` flag." + ) + } + } + }; + + Ok(environment_id) +} diff --git a/src/errors.rs b/src/errors.rs index 9805a792d..fbc802857 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -54,6 +54,9 @@ pub enum RailwayError { #[error("Project does not have any services")] NoServices, + #[error("Project does not have any environments")] + NoEnvironments, + #[error( "Environment \"{0}\" not found.\nRun `railway environment` to connect to an environment." )] diff --git a/src/util/prompt.rs b/src/util/prompt.rs index a3a96edb0..e9eec440b 100644 --- a/src/util/prompt.rs +++ b/src/util/prompt.rs @@ -12,7 +12,10 @@ use std::{ use crate::{ commands::{Configs, queries::project::ProjectProjectServicesEdgesNode}, controllers::variables::Variable, - queries::project::ProjectProjectEnvironmentsEdgesNodeServiceInstancesEdgesNode, + queries::project::{ + ProjectProjectEnvironmentsEdgesNode, + ProjectProjectEnvironmentsEdgesNodeServiceInstancesEdgesNode, + }, }; use anyhow::{Context, Result}; @@ -207,6 +210,15 @@ impl Display for PromptServiceInstance<'_> { } } +#[derive(Debug, Clone, PartialEq)] +pub struct PromptEnvironment<'a>(pub &'a ProjectProjectEnvironmentsEdgesNode); + +impl Display for PromptEnvironment<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.name) + } +} + /// Bash style completion of paths #[derive(Clone)] pub struct PathAutocompleter; From b5802c5975d67c31700331646fa480745118c029 Mon Sep 17 00:00:00 2001 From: Liam Cooper Date: Sat, 18 Apr 2026 17:32:28 -0700 Subject: [PATCH 3/3] ran through test cases, made some adjustments to better handle no env specified prompt / fail if json provided --- src/commands/service.rs | 2 +- src/controllers/environment.rs | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/commands/service.rs b/src/commands/service.rs index a38033011..9fe13c96a 100644 --- a/src/commands/service.rs +++ b/src/commands/service.rs @@ -167,7 +167,7 @@ async fn list_services(args: ListArgs) -> Result<()> { Some(linked_project) }; - let env_id = get_or_prompt_environment(linked, &project, args.environment) + let env_id = get_or_prompt_environment(linked, &project, args.environment, args.json) .await? .ok_or(RailwayError::NoEnvironments)?; diff --git a/src/controllers/environment.rs b/src/controllers/environment.rs index 053c058d5..2dd8dd6ed 100644 --- a/src/controllers/environment.rs +++ b/src/controllers/environment.rs @@ -3,9 +3,9 @@ use crate::{ commands::queries::{RailwayProject, project::ProjectProjectEnvironmentsEdgesNode}, errors::RailwayError, queries::project::ProjectProject, - util::prompt::{PromptEnvironment, prompt_select}, + util::prompt::{PromptEnvironment, prompt_select_with_cancel}, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Result, bail}; use is_terminal::IsTerminal; pub fn get_matched_environment( @@ -26,6 +26,7 @@ pub async fn get_or_prompt_environment( linked_project: Option, project: &ProjectProject, environment_arg: Option, + json: bool, ) -> Result> { let environments = project.environments.edges.iter().collect::>(); @@ -47,16 +48,20 @@ pub async fn get_or_prompt_environment( if environments.is_empty() { // If there are no environments, backboard will generate one for us None + } else if environments.len() == 1 { + // If there is just one, use that + Some(environments[0].node.id.clone()) } else { // If there are multiple environments, prompt the user to select one - if std::io::stdout().is_terminal() { + if std::io::stdout().is_terminal() && !json { let prompt_environments: Vec<_> = environments .iter() .map(|s| PromptEnvironment(&s.node)) .collect(); - let service = prompt_select("Select an environment", prompt_environments) - .context("Please specify an environment via the `--environment` flag.")?; - Some(service.0.id.clone()) + match prompt_select_with_cancel("Select an environment", prompt_environments)? { + Some(env) => Some(env.0.id.clone()), + None => bail!("No environment selected. Use --environment to specify one."), + } } else { bail!( "Multiple environments found. Please specify an environment via the `--environment` flag."