use anyhow::bail;
use is_terminal::IsTerminal;
use serde::Serialize;
use crate::{
client::post_graphql,
commands::output::service_summary::{ServiceOutput, build_service_output, print_service_card},
controllers::{
environment::get_matched_environment,
project::{
ensure_project_and_environment_exist, get_environment_instances, get_project,
get_service_ids_in_env, service_instances_in_env,
},
regions::fetch_region_locations,
},
errors::RailwayError,
util::{
progress::create_spinner_if,
prompt::{PromptService, fake_select, prompt_confirm_with_default, prompt_options},
two_factor::validate_two_factor_if_enabled,
},
};
use super::*;
pub fn get_dynamic_args(cmd: clap::Command) -> clap::Command {
cmd.mut_subcommand(
"scale",
crate::commands::scale::get_dynamic_args_for_service_subcommand,
)
}
#[derive(Parser)]
#[clap(
after_help = "Examples:\n\n railway service list --json\n railway service delete --service api --environment production --yes --json\n railway service rm --service api --yes --json\n railway service link api\n\nAutomation notes:\n Destructive non-interactive runs must pass exact selectors and --yes.\n Prefer service IDs from `railway service list --json` when names may collide."
)]
pub struct Args {
#[clap(subcommand)]
command: Option<Commands>,
service: Option<String>,
}
#[derive(Parser)]
enum Commands {
#[clap(visible_alias = "ls")]
List(ListArgs),
#[clap(visible_alias = "remove", visible_alias = "rm")]
Delete(DeleteArgs),
Link(LinkArgs),
Status(StatusArgs),
Logs(crate::commands::logs::Args),
Redeploy(crate::commands::redeploy::Args),
Restart(crate::commands::restart::Args),
Scale(crate::commands::scale::Args),
}
#[derive(Parser)]
struct LinkArgs {
service: Option<String>,
}
#[derive(Parser)]
struct ListArgs {
#[clap(short, long)]
environment: Option<String>,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct DeleteArgs {
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(short = 'y', long = "yes")]
yes: bool,
#[clap(long)]
json: bool,
#[clap(long = "2fa-code")]
two_factor_code: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ServiceStatusOutput {
id: String,
name: String,
deployment_id: Option<String>,
status: Option<String>,
stopped: bool,
}
#[derive(Parser)]
struct StatusArgs {
#[clap(short, long)]
service: Option<String>,
#[clap(short, long, hide = true)]
all: bool,
#[clap(short, long)]
environment: Option<String>,
#[clap(long)]
json: bool,
}
pub async fn command(args: Args) -> Result<()> {
if args.command.is_none() {
return link_command(LinkArgs {
service: args.service,
})
.await;
}
match args.command {
Some(Commands::List(list_args)) => list_command(list_args).await,
Some(Commands::Delete(delete_args)) => delete_command(delete_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,
Some(Commands::Redeploy(redeploy_args)) => {
crate::commands::redeploy::command(redeploy_args).await
}
Some(Commands::Restart(restart_args)) => {
crate::commands::restart::command(restart_args).await
}
Some(Commands::Scale(scale_args)) => crate::commands::scale::command(scale_args).await,
None => unreachable!(),
}
}
async fn list_command(args: ListArgs) -> Result<()> {
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
let (project, region_locations) = tokio::join!(
get_project(&client, &configs, linked_project.project.clone()),
fetch_region_locations(&client, &configs),
);
let project = project?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let env_id = if let Some(env_name) = args.environment {
get_matched_environment(&project, env_name)?.id
} else {
linked_project.environment_id()?.to_string()
};
let env_name = project
.environments
.edges
.iter()
.find(|env| env.node.id == env_id)
.map(|env| env.node.name.clone())
.expect("environment resolved above");
let environment_instances =
get_environment_instances(&client, &configs, &linked_project.project, &env_id).await?;
let service_ids_in_env = get_service_ids_in_env(&environment_instances);
let linked_service_id = linked_project.service.as_deref();
let mut services: Vec<_> = project
.services
.edges
.iter()
.filter(|edge| service_ids_in_env.contains(&edge.node.id))
.collect();
services.sort_by(|a, b| a.node.name.to_lowercase().cmp(&b.node.name.to_lowercase()));
let rows: Vec<ServiceOutput> = services
.iter()
.map(|edge| {
build_service_output(
&environment_instances,
&edge.node,
linked_service_id,
®ion_locations,
)
})
.collect();
if args.json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if rows.is_empty() {
println!("No services found in environment '{env_name}'");
return Ok(());
}
println!();
println!("{} {}", "Services in".bold(), env_name.blue().bold());
println!();
for row in &rows {
print_service_card(row, true);
}
Ok(())
}
async fn delete_command(args: DeleteArgs) -> Result<()> {
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
let local_linked_project = configs.get_local_linked_project().ok();
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
let is_terminal = std::io::stdout().is_terminal();
let environment =
resolve_environment_to_delete(&project, &linked_project, args.environment.as_deref())?;
let environment_id = environment.id.clone();
let environment_name = environment.name.clone();
let environment_instances =
get_environment_instances(&client, &configs, &linked_project.project, &environment_id)
.await?;
let service_ids_in_env = get_service_ids_in_env(&environment_instances);
let services_in_env: Vec<_> = project
.services
.edges
.iter()
.filter(|edge| service_ids_in_env.contains(&edge.node.id))
.map(|edge| &edge.node)
.collect();
let service = select_service_to_delete(
services_in_env,
args.service.as_deref(),
linked_project.service.as_deref(),
&environment_name,
!args.json,
is_terminal,
)?;
let service_id = service.id.clone();
let service_name = service.name.clone();
let confirmed = if args.yes {
true
} else if is_terminal {
prompt_confirm_with_default(
format!(
"Are you sure you want to delete service \"{}\" from environment \"{}\"? This will permanently delete all its deployments.",
service_name, environment_name
)
.as_str(),
false,
)?
} else {
bail!(
"Cannot prompt for confirmation in non-interactive mode. Use --yes to skip confirmation."
);
};
if !confirmed {
if !args.json {
println!("Deletion cancelled.");
}
return Ok(());
}
validate_two_factor_if_enabled(&client, &configs, is_terminal, args.two_factor_code).await?;
let spinner = create_spinner_if(!args.json, format!("Deleting service {}...", service_name));
post_graphql::<mutations::ServiceDelete, _>(
&client,
configs.get_backboard(),
mutations::service_delete::Variables {
service_id: service_id.clone(),
environment_id: environment_id.clone(),
},
)
.await?;
let unlink_path = local_linked_project.as_ref().and_then(|project| {
(project.project == linked_project.project
&& project.service.as_deref() == Some(service_id.as_str())
&& project.environment_id().ok() == Some(environment_id.as_str()))
.then(|| project.project_path.clone())
});
let should_unlink = unlink_path.is_some();
if let Some(path) = unlink_path {
let linked_project = configs
.root_config
.projects
.get_mut(&path)
.ok_or(RailwayError::ProjectNotFound)?;
linked_project.service = None;
configs.write()?;
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"id": service_id,
"name": service_name,
"environmentId": environment_id,
"environmentName": environment_name,
"unlinked": should_unlink,
}))?
);
} else if let Some(spinner) = spinner {
spinner.finish_with_message(format!(
"Deleted service {} from {}",
service_name.green(),
environment_name.blue()
));
}
Ok(())
}
fn resolve_environment_to_delete(
project: &crate::gql::queries::project::ProjectProject,
linked_project: &crate::LinkedProject,
environment_arg: Option<&str>,
) -> Result<crate::gql::queries::project::ProjectProjectEnvironmentsEdgesNode> {
if project.deleted_at.is_some() {
bail!(RailwayError::ProjectDeleted);
}
let environment = if let Some(environment_arg) = environment_arg {
get_matched_environment(project, environment_arg.to_string())?
} else {
let linked_environment = match linked_project
.environment_name
.clone()
.or_else(|| linked_project.environment.clone())
{
Some(environment) => environment,
None => linked_project.environment_id()?.to_string(),
};
get_matched_environment(project, linked_environment)?
};
if environment.deleted_at.is_some() {
bail!(RailwayError::EnvironmentDeleted);
}
Ok(environment)
}
fn select_service_to_delete<'a>(
services_in_env: Vec<&'a crate::gql::queries::project::ProjectProjectServicesEdgesNode>,
service_arg: Option<&str>,
linked_service_id: Option<&str>,
environment_name: &str,
echo_selection: bool,
is_terminal: bool,
) -> Result<&'a crate::gql::queries::project::ProjectProjectServicesEdgesNode> {
if services_in_env.is_empty() {
bail!("No services found in environment '{}'", environment_name);
}
if let Some(service_arg) = service_arg {
let service = services_in_env
.iter()
.copied()
.find(|service| {
service.id.eq_ignore_ascii_case(service_arg)
|| service.name.eq_ignore_ascii_case(service_arg)
})
.ok_or_else(|| {
anyhow::anyhow!(
"Service \"{}\" not found in environment '{}'",
service_arg,
environment_name
)
})?;
if echo_selection {
fake_select("Select a service to delete", &service.name);
}
return Ok(service);
}
if let Some(linked_service_id) = linked_service_id {
if let Some(service) = services_in_env
.iter()
.copied()
.find(|service| service.id == linked_service_id)
{
return Ok(service);
}
}
if !is_terminal {
bail!(
"Service must be specified when not running in a terminal. Use --service <id or name>"
);
}
let service = prompt_options(
"Select a service to delete",
services_in_env.iter().copied().map(PromptService).collect(),
)?;
Ok(service.0)
}
async fn link_command(args: LinkArgs) -> Result<()> {
let mut configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let environment_instances = get_environment_instances(
&client,
&configs,
&linked_project.project,
linked_project.environment_id()?,
)
.await?;
let service_ids_in_env = get_service_ids_in_env(&environment_instances);
let services: Vec<_> = project
.services
.edges
.iter()
.filter(|a| service_ids_in_env.contains(&a.node.id))
.map(|s| PromptService(&s.node))
.collect();
let service = if let Some(name) = args.service {
services
.into_iter()
.find(|s| s.0.id.eq_ignore_ascii_case(&name) || s.0.name.eq_ignore_ascii_case(&name))
.ok_or_else(|| RailwayError::ServiceNotFound(name))?
} else if services.is_empty() {
bail!("No services found")
} else {
if !std::io::stdout().is_terminal() {
bail!("Service name required in non-interactive mode. Usage: railway service <name>");
}
prompt_options("Select a service", services)?
};
configs.link_service(service.0.id.clone())?;
configs.write()?;
println!("Linked service {}", service.0.name.green());
Ok(())
}
async fn status_command(args: StatusArgs) -> Result<()> {
if args.all {
eprintln!(
"{}",
"Warning: `railway service status --all` is deprecated. Please use `railway service list` instead."
.yellow()
);
}
let configs = Configs::new()?;
let client = GQLClient::new_authorized(&configs)?;
let linked_project = configs.get_linked_project().await?;
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let environment_id = if let Some(env_name) = args.environment {
let env = get_matched_environment(&project, env_name)?;
env.id
} else {
linked_project.environment_id()?.to_string()
};
let environment_name = project
.environments
.edges
.iter()
.find(|env| env.node.id == environment_id)
.map(|env| env.node.name.clone())
.context("Environment not found")?;
let mut service_statuses: Vec<ServiceStatusOutput> = Vec::new();
let environment_instances =
get_environment_instances(&client, &configs, &linked_project.project, &environment_id)
.await?;
for instance_edge in service_instances_in_env(&environment_instances) {
let instance = &instance_edge.node;
let deployment = &instance.latest_deployment;
service_statuses.push(ServiceStatusOutput {
id: instance.service_id.clone(),
name: instance.service_name.clone(),
deployment_id: deployment.as_ref().map(|d| d.id.clone()),
status: deployment.as_ref().map(|d| format!("{:?}", d.status)),
stopped: deployment
.as_ref()
.map(|d| d.deployment_stopped)
.unwrap_or(false),
});
}
if args.all {
if args.json {
println!("{}", serde_json::to_string_pretty(&service_statuses)?);
} else {
if service_statuses.is_empty() {
println!("No services found in environment '{}'", environment_name);
return Ok(());
}
println!("Services in {}:\n", environment_name.blue().bold());
for status in service_statuses {
let status_display = format_status_display(&status);
println!(
"{:<20} | {:<14} | {}",
status.name.bold(),
status.deployment_id.as_deref().unwrap_or("N/A").dimmed(),
status_display
);
}
}
} else {
let target_service = if let Some(service_name) = args.service {
service_statuses
.iter()
.find(|s| s.id == service_name || s.name == service_name)
.ok_or_else(|| RailwayError::ServiceNotFound(service_name.clone()))?
} else {
let linked_service_id = linked_project
.service
.as_ref()
.context("No service linked. Use --service flag or --all to see all services")?;
service_statuses
.iter()
.find(|s| &s.id == linked_service_id)
.context("Linked service not found in this environment")?
};
if args.json {
println!("{}", serde_json::to_string_pretty(&target_service)?);
} else {
println!("Service: {}", target_service.name.green().bold());
println!(
"Deployment: {}",
target_service
.deployment_id
.as_deref()
.unwrap_or("No deployment")
.dimmed()
);
println!("Status: {}", format_status_display(target_service));
}
}
Ok(())
}
fn format_status_display(status: &ServiceStatusOutput) -> colored::ColoredString {
if status.stopped && status.status.as_deref() == Some("SUCCESS") {
return "STOPPED".yellow();
}
match status.status.as_deref() {
Some("SUCCESS") => "SUCCESS".green(),
Some("FAILED") | Some("CRASHED") => status.status.as_deref().unwrap_or("UNKNOWN").red(),
Some("BUILDING") | Some("DEPLOYING") | Some("INITIALIZING") | Some("QUEUED") => {
status.status.as_deref().unwrap_or("UNKNOWN").blue()
}
Some("SLEEPING") => "SLEEPING".yellow(),
Some("REMOVED") | Some("REMOVING") => {
status.status.as_deref().unwrap_or("UNKNOWN").dimmed()
}
Some(s) => s.white(),
None => "NO DEPLOYMENT".dimmed(),
}
}