use super::*;
use crate::{
controllers::{
project::resolve_service_context,
variables::{Variable, get_service_variables},
},
table::Table,
util::progress::create_spinner_if,
};
use anyhow::bail;
use std::io::{IsTerminal, Read};
#[derive(Parser)]
pub struct Args {
#[clap(subcommand)]
command: Option<Commands>,
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(short, long)]
kv: bool,
#[clap(long)]
set: Vec<Variable>,
#[clap(long, value_name = "KEY")]
set_from_stdin: Option<String>,
#[clap(long)]
json: bool,
#[clap(long)]
skip_deploys: bool,
}
#[derive(Parser)]
enum Commands {
#[clap(alias = "ls")]
List(ListArgs),
Set(SetArgs),
#[clap(alias = "rm", alias = "remove")]
Delete(DeleteArgs),
}
#[derive(Parser)]
struct ListArgs {
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(short, long)]
kv: bool,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct SetArgs {
#[clap(required = true)]
variables: Vec<String>,
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(long)]
stdin: bool,
#[clap(long)]
skip_deploys: bool,
#[clap(long)]
json: bool,
}
#[derive(Parser)]
struct DeleteArgs {
key: String,
#[clap(short, long)]
service: Option<String>,
#[clap(short, long)]
environment: Option<String>,
#[clap(long)]
json: bool,
}
pub async fn command(args: Args) -> Result<()> {
if let Some(cmd) = args.command {
return match cmd {
Commands::List(list_args) => list_variables(list_args).await,
Commands::Set(set_args) => set_variable(set_args).await,
Commands::Delete(delete_args) => delete_variable(delete_args).await,
};
}
if let Some(key) = args.set_from_stdin {
let value = read_value_from_stdin()?;
let variable = Variable { key, value };
return set_variables_legacy(
vec![variable],
args.service,
args.environment,
args.skip_deploys,
)
.await;
}
if !args.set.is_empty() {
return set_variables_legacy(args.set, args.service, args.environment, args.skip_deploys)
.await;
}
list_variables(ListArgs {
service: args.service,
environment: args.environment,
kv: args.kv,
json: args.json,
})
.await
}
async fn list_variables(args: ListArgs) -> Result<()> {
let ctx = resolve_service_context(args.service, args.environment).await?;
let variables = get_service_variables(
&ctx.client,
&ctx.configs,
ctx.project.id.clone(),
ctx.environment_id,
ctx.service_id,
)
.await?;
if args.kv {
for (key, value) in variables {
println!("{key}={value}");
}
return Ok(());
}
if args.json {
println!("{}", serde_json::to_string_pretty(&variables)?);
return Ok(());
}
if variables.is_empty() {
eprintln!("No variables found");
return Ok(());
}
let table = Table::new(ctx.service_name, variables);
table.print()?;
Ok(())
}
async fn set_variable(args: SetArgs) -> Result<()> {
let variables = if args.stdin {
if args.variables.len() != 1 {
bail!("--stdin requires exactly one KEY argument");
}
let key = &args.variables[0];
if key.contains('=') {
bail!(
"Cannot use --stdin with KEY=VALUE format. Use: railway variable set KEY --stdin"
);
}
let value = read_value_from_stdin()?;
vec![Variable {
key: key.clone(),
value,
}]
} else {
args.variables
.iter()
.map(|s| s.parse::<Variable>())
.collect::<Result<Vec<_>, _>>()?
};
set_variables_internal(
variables,
args.service,
args.environment,
args.skip_deploys,
args.json,
)
.await
}
async fn delete_variable(args: DeleteArgs) -> Result<()> {
let ctx = resolve_service_context(args.service, args.environment).await?;
let spinner = create_spinner_if(!args.json, format!("Deleting {}...", args.key.bold()));
let vars = mutations::variable_delete::Variables {
project_id: ctx.project_id,
environment_id: ctx.environment_id,
name: args.key.clone(),
service_id: Some(ctx.service_id),
};
post_graphql::<mutations::VariableDelete, _>(&ctx.client, ctx.configs.get_backboard(), vars)
.await?;
if let Some(sp) = spinner {
sp.finish_with_message(format!("Deleted variable {}", args.key.bold()));
} else {
println!("{}", serde_json::json!({"key": args.key, "deleted": true}));
}
Ok(())
}
async fn set_variables_legacy(
variables: Vec<Variable>,
service: Option<String>,
environment: Option<String>,
skip_deploys: bool,
) -> Result<()> {
set_variables_internal(variables, service, environment, skip_deploys, false).await
}
async fn set_variables_internal(
variables: Vec<Variable>,
service: Option<String>,
environment: Option<String>,
skip_deploys: bool,
json: bool,
) -> Result<()> {
let ctx = resolve_service_context(service, environment).await?;
let keys: Vec<String> = variables.iter().map(|v| v.key.clone()).collect();
let fmt_keys = keys
.iter()
.map(|k| k.bold().to_string())
.collect::<Vec<_>>()
.join(", ");
let spinner = create_spinner_if(!json, format!("Setting {fmt_keys}..."));
let vars = mutations::variable_collection_upsert::Variables {
project_id: ctx.project_id,
environment_id: ctx.environment_id,
service_id: ctx.service_id,
variables: variables.into_iter().map(|v| (v.key, v.value)).collect(),
skip_deploys: skip_deploys.then_some(true),
};
post_graphql::<mutations::VariableCollectionUpsert, _>(
&ctx.client,
ctx.configs.get_backboard(),
vars,
)
.await?;
if let Some(sp) = spinner {
sp.finish_with_message(format!("Set variables {fmt_keys}"));
} else {
println!("{}", serde_json::json!({"keys": keys, "set": true}));
}
Ok(())
}
fn read_value_from_stdin() -> Result<String> {
let stdin = std::io::stdin();
if stdin.is_terminal() {
bail!(
"No input provided via stdin. Use --stdin with piped input, e.g.:\n echo \"value\" | railway variable set KEY --stdin"
);
}
let mut value = String::new();
stdin.lock().read_to_string(&mut value)?;
let value = value.trim_end_matches('\n').trim_end_matches('\r');
if value.is_empty() {
bail!("Empty value provided via stdin");
}
Ok(value.to_string())
}