diff --git a/src/commands/service.rs b/src/commands/service.rs index f198b85d8..9fe13c96a 100644 --- a/src/commands/service.rs +++ b/src/commands/service.rs @@ -4,11 +4,12 @@ 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, 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,73 @@ 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_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 + .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 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.clone()).await?; + let linked = if project_explicitly_specified { + None + } else { + Some(linked_project) + }; + + let env_id = get_or_prompt_environment(linked, &project, args.environment, args.json) + .await? + .ok_or(RailwayError::NoEnvironments)?; + + 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)?; diff --git a/src/controllers/environment.rs b/src/controllers/environment.rs index 8bc914e14..2dd8dd6ed 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_with_cancel}, }; -use anyhow::Result; +use anyhow::{Result, bail}; +use is_terminal::IsTerminal; pub fn get_matched_environment( project: &RailwayProject, @@ -17,3 +21,54 @@ pub fn get_matched_environment( Ok(environment.node.clone()) } + +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::>(); + + 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 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() && !json { + let prompt_environments: Vec<_> = environments + .iter() + .map(|s| PromptEnvironment(&s.node)) + .collect(); + 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." + ) + } + } + }; + + 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;