mirror of
https://github.com/railwayapp/cli
synced 2026-04-21 14:07:23 +00:00
fix(config): make RAILWAY_ENVIRONMENT_ID optional for project-level commands (#826)
* fix(config): make RAILWAY_ENVIRONMENT_ID optional for project-level commands Commands like `env new`, `env delete`, and `env link` only need a project ID, not an environment. The previous validation required both RAILWAY_PROJECT_ID and RAILWAY_ENVIRONMENT_ID to be set together, which made these commands unusable with just RAILWAY_PROJECT_ID. Make `LinkedProject.environment` an `Option<String>` and remove the blanket XOR validation. Commands that need an environment now error at the point of use with a clear message, while project-level commands work with just RAILWAY_PROJECT_ID set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: context-aware error messages for missing environment When RAILWAY_PROJECT_ID is set, tell the user to set RAILWAY_ENVIRONMENT_ID (since `railway environment` writes to local config which is ignored when env vars take priority). When using local config, suggest `railway environment` as before. Also consolidates duplicated error messages across callers into the central `environment_id()` helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: simplify error message for missing environment The environment is only None when using env-var targeting (local config always has an environment set via link_project), so drop the unreachable branch. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject env-only override and defer environment preflight P1: Reject RAILWAY_ENVIRONMENT_ID set without RAILWAY_PROJECT_ID instead of silently falling back to local config. P2: Make ensure_project_and_environment_exist() skip the environment check when no environment is linked. Callers that accept --environment resolve and validate it themselves, so the preflight no longer blocks them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add tests for env var project targeting validation Extract resolve_env_var_project() from get_linked_project() so the env var validation logic can be tested without Configs/auth/API deps. Two tests: - PROJECT_ID alone → succeeds with environment None - ENVIRONMENT_ID alone → rejected Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: improve error messages and graceful status for missing environment Provide actionable guidance (set env var, use --environment, or run railway environment) and show "None" in status instead of crashing when no environment is linked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix rustfmt formatting in status.rs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: merge locally linked environment in project-only env-var mode When RAILWAY_PROJECT_ID is set without RAILWAY_ENVIRONMENT_ID, fall back to the environment from the local config so that `railway environment` remains a valid remediation path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only merge local environment when project IDs match Prevents silently using project A's environment with project B when RAILWAY_PROJECT_ID overrides the locally linked project. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve stale env name, nested dir lookup, and down panic in env-var mode - Don't carry local environment_name when RAILWAY_ENVIRONMENT_ID override is set, preventing preflight from validating against the wrong environment - Use ancestor-walking lookup for local project so nested directories still find the linked config in env-var mode - Replace expect() with fallback in `down` command to avoid panic when project/environment names are absent Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cc8cc6ec0d
commit
3ca7e63637
27 changed files with 240 additions and 113 deletions
|
|
@ -320,7 +320,7 @@ async fn create_service(
|
|||
let vars = mutations::service_create::Variables {
|
||||
name: service,
|
||||
project_id: linked_project.project.clone(),
|
||||
environment_id: linked_project.environment.clone(),
|
||||
environment_id: linked_project.environment_id()?.to_string(),
|
||||
source: Some(source),
|
||||
variables,
|
||||
branch,
|
||||
|
|
|
|||
|
|
@ -137,10 +137,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
|
||||
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
let environment_input = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment_input = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
let environment = get_matched_environment(&project, environment_input)?;
|
||||
let environment_config = fetch_environment_config(&client, &configs, &environment.id, false)
|
||||
.await?
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
let configs = Configs::new()?;
|
||||
let client = GQLClient::new_authorized(&configs)?;
|
||||
let linked_project = configs.get_linked_project().await?;
|
||||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ pub async fn fetch_and_create(
|
|||
|
||||
let mutation_vars = mutations::template_deploy::Variables {
|
||||
project_id: linked_project.project.clone(),
|
||||
environment_id: linked_project.environment.clone(),
|
||||
environment_id: linked_project.environment_id()?.to_string(),
|
||||
template_id: details.template.id.clone(),
|
||||
serialized_config: serde_json::to_value(&config).context("Failed to serialize config")?,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -108,7 +108,10 @@ async fn list_deployments(
|
|||
};
|
||||
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
let environment = environment.unwrap_or(linked_project.environment.clone());
|
||||
let environment = match environment {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
let environment_id = get_matched_environment(&project, environment)?.id;
|
||||
|
||||
let service_id = if let Some(service_name_or_id) = service {
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ async fn configure_command(args: ConfigureArgs) -> Result<()> {
|
|||
.collect();
|
||||
|
||||
let project_id = linked_project.project.clone();
|
||||
let environment_id = linked_project.environment.clone();
|
||||
let environment_id = linked_project.environment_id()?.to_string();
|
||||
|
||||
let env_response = fetch_environment_config(&client, &configs, &environment_id, false).await?;
|
||||
let config = env_response.config;
|
||||
|
|
@ -841,10 +841,10 @@ async fn up_command(args: UpArgs) -> Result<()> {
|
|||
.collect();
|
||||
|
||||
let project_id = linked_project.project.clone();
|
||||
let environment_id = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment_id = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
|
||||
let env_response = fetch_environment_config(&client, &configs, &environment_id, true).await?;
|
||||
let env_name = env_response.name;
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ async fn create_service_domain(service_name: Option<String>, json: bool) -> Resu
|
|||
|
||||
let vars = queries::domains::Variables {
|
||||
project_id: linked_project.project.clone(),
|
||||
environment_id: linked_project.environment.clone(),
|
||||
environment_id: linked_project.environment_id()?.to_string(),
|
||||
service_id: service.id.clone(),
|
||||
};
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ async fn create_service_domain(service_name: Option<String>, json: bool) -> Resu
|
|||
|
||||
let vars = mutations::service_domain_create::Variables {
|
||||
service_id: service.id.clone(),
|
||||
environment_id: linked_project.environment.clone(),
|
||||
environment_id: linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
let domain =
|
||||
post_graphql::<mutations::ServiceDomainCreate, _>(&client, configs.get_backboard(), vars)
|
||||
|
|
@ -264,7 +264,7 @@ async fn create_custom_domain(
|
|||
let vars = mutations::custom_domain_create::Variables {
|
||||
input: mutations::custom_domain_create::CustomDomainCreateInput {
|
||||
domain: domain.clone(),
|
||||
environment_id: linked_project.environment.clone(),
|
||||
environment_id: linked_project.environment_id()?.to_string(),
|
||||
project_id: linked_project.project.clone(),
|
||||
service_id: service.id.clone(),
|
||||
target_port: port.map(|p| p as i64),
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
|
||||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
|
||||
let services = project.services.edges.iter().collect::<Vec<_>>();
|
||||
|
||||
|
|
@ -72,11 +72,13 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
|
||||
let linked_project_name = linked_project
|
||||
.name
|
||||
.expect("Linked project is missing the name");
|
||||
.as_deref()
|
||||
.unwrap_or(linked_project.project.as_str());
|
||||
|
||||
let linked_environment_name = linked_project
|
||||
.environment_name
|
||||
.expect("Linked environment is missing the name");
|
||||
.as_deref()
|
||||
.unwrap_or(linked_project.environment.as_deref().unwrap_or("unknown"));
|
||||
|
||||
let linked_project_environment = format!(
|
||||
"{} environment of project {}",
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ fn resolve_environment(
|
|||
bail!(RailwayError::EnvironmentNotFound(env_input.clone()))
|
||||
}
|
||||
} else {
|
||||
let env_id = linked_project.environment.clone();
|
||||
let env_id = linked_project.environment_id()?.to_string();
|
||||
let env_name = project
|
||||
.environments
|
||||
.edges
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ fn resolve_environment(
|
|||
}
|
||||
} else {
|
||||
// Use linked environment
|
||||
let env_id = linked_project.environment.clone();
|
||||
let env_id = linked_project.environment_id()?.to_string();
|
||||
let env_name = project
|
||||
.environments
|
||||
.edges
|
||||
|
|
|
|||
|
|
@ -111,10 +111,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
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?;
|
||||
let environment_input = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment_input = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
let environment = project
|
||||
.environments
|
||||
.edges
|
||||
|
|
|
|||
|
|
@ -294,10 +294,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
|
||||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
|
||||
let services = project.services.edges.iter().collect::<Vec<_>>();
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ impl RailwayMcp {
|
|||
.map_err(|e| McpError::internal_error(format!("Failed to get project: {e}"), None))?;
|
||||
|
||||
let env_id_or_name = environment_id
|
||||
.or_else(|| linked.as_ref().map(|l| l.environment.clone()))
|
||||
.or_else(|| linked.as_ref().and_then(|l| l.environment.clone()))
|
||||
.ok_or_else(|| {
|
||||
let available = format_environments(&project);
|
||||
McpError::invalid_params(
|
||||
|
|
@ -689,7 +689,7 @@ impl RailwayMcp {
|
|||
.map_err(|e| McpError::internal_error(format!("Failed to get project: {e}"), None))?;
|
||||
|
||||
// Filter to services present in the linked environment (matches CLI behavior)
|
||||
let environment_id = linked.as_ref().map(|l| l.environment.as_str());
|
||||
let environment_id = linked.as_ref().and_then(|l| l.environment.as_deref());
|
||||
let env_service_ids = environment_id
|
||||
.and_then(|eid| project.environments.edges.iter().find(|e| e.node.id == eid))
|
||||
.map(|e| {
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ impl RailwayMcp {
|
|||
let linked = self.configs.get_linked_project().await.ok();
|
||||
let environment_id = params
|
||||
.environment_id
|
||||
.or_else(|| linked.as_ref().map(|l| l.environment.clone()))
|
||||
.or_else(|| linked.as_ref().and_then(|l| l.environment.clone()))
|
||||
.ok_or_else(|| {
|
||||
McpError::invalid_params(
|
||||
"environment_id is required when updating mount_path.",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
|
||||
let url = format!(
|
||||
"https://{hostname}/project/{}?environmentId={}",
|
||||
linked_project.project, linked_project.environment
|
||||
linked_project.project,
|
||||
linked_project.environment_id()?
|
||||
);
|
||||
|
||||
if args.print || !std::io::stdout().is_terminal() {
|
||||
|
|
|
|||
|
|
@ -49,9 +49,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
.ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_id)))?;
|
||||
|
||||
let service_in_env =
|
||||
find_service_instance(&project, &linked_project.environment, &service.node.id).ok_or_else(
|
||||
|| anyhow!("The service specified doesn't exist in the current environment"),
|
||||
)?;
|
||||
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")
|
||||
})?;
|
||||
|
||||
let Some(ref latest) = service_in_env.latest_deployment else {
|
||||
bail!("No deployment found for service")
|
||||
|
|
|
|||
|
|
@ -59,9 +59,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
.ok_or_else(|| anyhow!(RailwayError::ServiceNotFound(service_id)))?;
|
||||
|
||||
let service_in_env =
|
||||
find_service_instance(&project, &linked_project.environment, &service.node.id).ok_or_else(
|
||||
|| anyhow!("The service specified doesn't exist in the current environment"),
|
||||
)?;
|
||||
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")
|
||||
})?;
|
||||
|
||||
let Some(ref latest) = service_in_env.latest_deployment else {
|
||||
bail!("No deployment found for service")
|
||||
|
|
|
|||
|
|
@ -118,10 +118,14 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.or_else(|| linked_project.as_ref().map(|lp| lp.environment.clone()))
|
||||
.or_else(|| {
|
||||
linked_project
|
||||
.as_ref()
|
||||
.and_then(|lp| lp.environment.clone())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No environment specified. Use --environment or run `railway link` first"
|
||||
"No environment specified. Set RAILWAY_ENVIRONMENT_ID, use --environment, or run `railway environment` to link one."
|
||||
)
|
||||
})?;
|
||||
|
||||
|
|
|
|||
|
|
@ -53,10 +53,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
)
|
||||
.await?
|
||||
.project;
|
||||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
let (existing, service_id) =
|
||||
get_existing_config(&args, &linked_project, &project, &environment)?;
|
||||
let new_config = convert_hashmap_to_map(
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ async fn link_command(args: LinkArgs) -> Result<()> {
|
|||
|
||||
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
|
||||
|
||||
let service_ids_in_env = get_service_ids_in_env(&project, &linked_project.environment);
|
||||
let service_ids_in_env = get_service_ids_in_env(&project, linked_project.environment_id()?);
|
||||
let services: Vec<_> = project
|
||||
.services
|
||||
.edges
|
||||
|
|
@ -156,7 +156,7 @@ async fn status_command(args: StatusArgs) -> Result<()> {
|
|||
let env = get_matched_environment(&project, env_name)?;
|
||||
env.id
|
||||
} else {
|
||||
linked_project.environment.clone()
|
||||
linked_project.environment_id()?.to_string()
|
||||
};
|
||||
|
||||
let environment_name = project
|
||||
|
|
|
|||
|
|
@ -63,18 +63,18 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
&client,
|
||||
&configs,
|
||||
linked_project.project.clone(),
|
||||
linked_project.environment.clone(),
|
||||
linked_project.environment_id()?.to_string(),
|
||||
service_id.node.id.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
all_variables.append(&mut variables);
|
||||
} else if let Some(service) = linked_project.service {
|
||||
} else if let Some(ref service) = linked_project.service {
|
||||
let mut variables = get_service_variables(
|
||||
&client,
|
||||
&configs,
|
||||
linked_project.project.clone(),
|
||||
linked_project.environment.clone(),
|
||||
linked_project.environment_id()?.to_string(),
|
||||
service.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -119,7 +119,11 @@ pub async fn get_ssh_connect_params(
|
|||
let environment = if let Some(env) = args.environment {
|
||||
env
|
||||
} else {
|
||||
linked_project.as_ref().unwrap().environment.clone()
|
||||
linked_project
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.environment_id()?
|
||||
.to_string()
|
||||
};
|
||||
let environment_id = get_matched_environment(&project, environment)?.id;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,19 +20,18 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
|
||||
if !args.json {
|
||||
println!("Project: {}", project.name.purple().bold());
|
||||
println!(
|
||||
"Environment: {}",
|
||||
if let Some(env_name) = linked_project.environment.as_deref().and_then(|eid| {
|
||||
project
|
||||
.environments
|
||||
.edges
|
||||
.iter()
|
||||
.map(|env| &env.node)
|
||||
.find(|env| env.id == linked_project.environment)
|
||||
.context("Environment not found!")?
|
||||
.name
|
||||
.blue()
|
||||
.bold()
|
||||
);
|
||||
.find(|env| env.node.id == eid)
|
||||
.map(|env| env.node.name.as_str())
|
||||
}) {
|
||||
println!("Environment: {}", env_name.blue().bold());
|
||||
} else {
|
||||
println!("Environment: {}", "None".red().bold());
|
||||
}
|
||||
|
||||
if let Some(linked_service) = linked_project.service {
|
||||
if let Some(service) = project
|
||||
|
|
|
|||
|
|
@ -99,10 +99,14 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.or_else(|| linked_project.as_ref().map(|lp| lp.environment.clone()))
|
||||
.or_else(|| {
|
||||
linked_project
|
||||
.as_ref()
|
||||
.and_then(|lp| lp.environment.clone())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No environment specified. Use --environment or run `railway link` first"
|
||||
"No environment specified. Set RAILWAY_ENVIRONMENT_ID, use --environment, or run `railway environment` to link one."
|
||||
)
|
||||
})?;
|
||||
let environment_id = get_matched_environment(&project, environment)?.id;
|
||||
|
|
|
|||
|
|
@ -134,10 +134,10 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
let service = args.service.or_else(|| linked_project.service.clone());
|
||||
let environment = args
|
||||
.environment
|
||||
.clone()
|
||||
.unwrap_or(linked_project.environment.clone());
|
||||
let environment = match args.environment.clone() {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
|
||||
match args.command {
|
||||
Commands::Add(a) => add(service, environment, a.mount_path, project, a.json).await?,
|
||||
|
|
|
|||
155
src/config.rs
155
src/config.rs
|
|
@ -24,11 +24,22 @@ pub struct LinkedProject {
|
|||
pub project_path: String,
|
||||
pub name: Option<String>,
|
||||
pub project: String,
|
||||
pub environment: String,
|
||||
pub environment: Option<String>,
|
||||
pub environment_name: Option<String>,
|
||||
pub service: Option<String>,
|
||||
}
|
||||
|
||||
impl LinkedProject {
|
||||
/// Returns the environment ID, or an error if no environment is linked.
|
||||
pub fn environment_id(&self) -> Result<&str> {
|
||||
self.environment.as_deref().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"No environment specified. Set RAILWAY_ENVIRONMENT_ID, use --environment, or run `railway environment` to link one."
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
#[serde_with::skip_serializing_none]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -302,44 +313,47 @@ impl Configs {
|
|||
project_path: self.get_current_directory()?,
|
||||
name: Some(data.project_token.project.name),
|
||||
project: data.project_token.project.id,
|
||||
environment: data.project_token.environment.id,
|
||||
environment: Some(data.project_token.environment.id),
|
||||
environment_name: Some(data.project_token.environment.name),
|
||||
service: project.cloned().and_then(|p| p.service),
|
||||
};
|
||||
return Ok(project);
|
||||
}
|
||||
|
||||
let has_project_id = Self::get_railway_project_id().is_some();
|
||||
let has_environment_id = Self::get_railway_environment_id().is_some();
|
||||
|
||||
if has_project_id != has_environment_id {
|
||||
bail!(
|
||||
"Both RAILWAY_PROJECT_ID and RAILWAY_ENVIRONMENT_ID must be set together. {} is missing.",
|
||||
if has_project_id {
|
||||
"RAILWAY_ENVIRONMENT_ID"
|
||||
} else {
|
||||
"RAILWAY_PROJECT_ID"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if let (Some(project_id), Some(environment_id)) = (
|
||||
Self::get_railway_project_id(),
|
||||
Self::get_railway_environment_id(),
|
||||
) {
|
||||
if let Some(resolved) = Self::resolve_env_var_project()? {
|
||||
if self.get_railway_auth_token().is_none() {
|
||||
bail!(RailwayError::Unauthorized);
|
||||
}
|
||||
|
||||
let service_id =
|
||||
Self::get_railway_service_id().or_else(|| project.cloned().and_then(|p| p.service));
|
||||
// Only merge local config when it targets the same project,
|
||||
// to avoid silently mixing project A's environment with project B.
|
||||
// Walk ancestor directories so nested dirs still find the local link.
|
||||
let local = self
|
||||
.get_local_linked_project()
|
||||
.ok()
|
||||
.filter(|p| p.project == resolved.project_id);
|
||||
let service_id = Self::get_railway_service_id()
|
||||
.or_else(|| local.as_ref().and_then(|p| p.service.clone()));
|
||||
|
||||
let env_from_override = resolved.environment_id.is_some();
|
||||
let environment = resolved
|
||||
.environment_id
|
||||
.or_else(|| local.as_ref().and_then(|p| p.environment.clone()));
|
||||
// Only carry the local environment name when we fell back to the
|
||||
// local environment ID. If the override supplied its own ID, the
|
||||
// local name would refer to a different environment.
|
||||
let environment_name = if !env_from_override && environment.is_some() {
|
||||
local.as_ref().and_then(|p| p.environment_name.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
return Ok(LinkedProject {
|
||||
project_path: self.get_current_directory()?,
|
||||
name: None,
|
||||
project: project_id,
|
||||
environment: environment_id,
|
||||
environment_name: None,
|
||||
project: resolved.project_id,
|
||||
environment,
|
||||
environment_name,
|
||||
service: service_id,
|
||||
});
|
||||
}
|
||||
|
|
@ -368,7 +382,7 @@ impl Configs {
|
|||
project_path: path.clone(),
|
||||
name,
|
||||
project: project_id,
|
||||
environment: environment_id,
|
||||
environment: Some(environment_id),
|
||||
environment_name,
|
||||
service: None,
|
||||
};
|
||||
|
|
@ -516,4 +530,93 @@ impl Configs {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves env-var-based project targeting. Returns:
|
||||
/// - `Ok(Some(...))` if RAILWAY_PROJECT_ID is set (with optional environment)
|
||||
/// - `Ok(None)` if neither env var is set (fall through to local config)
|
||||
/// - `Err(...)` if RAILWAY_ENVIRONMENT_ID is set without RAILWAY_PROJECT_ID
|
||||
fn resolve_env_var_project() -> Result<Option<ResolvedEnvVarProject>> {
|
||||
let project_id = Self::get_railway_project_id();
|
||||
let environment_id = Self::get_railway_environment_id();
|
||||
|
||||
match (project_id, environment_id) {
|
||||
(Some(project_id), env_id) => Ok(Some(ResolvedEnvVarProject {
|
||||
project_id,
|
||||
environment_id: env_id,
|
||||
})),
|
||||
(None, Some(_)) => {
|
||||
bail!("RAILWAY_ENVIRONMENT_ID cannot be set without RAILWAY_PROJECT_ID.")
|
||||
}
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ResolvedEnvVarProject {
|
||||
project_id: String,
|
||||
environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Env var tests must run sequentially to avoid races.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_env_vars<F, R>(vars: &[(&str, Option<&str>)], f: F) -> R
|
||||
where
|
||||
F: FnOnce() -> R,
|
||||
{
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
// SAFETY: tests run sequentially under ENV_LOCK, so no concurrent mutation.
|
||||
unsafe {
|
||||
for (key, val) in vars {
|
||||
match val {
|
||||
Some(v) => std::env::set_var(key, v),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = f();
|
||||
unsafe {
|
||||
for (key, _) in vars {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_project_id_only_returns_none_environment() {
|
||||
let result = with_env_vars(
|
||||
&[
|
||||
("RAILWAY_PROJECT_ID", Some("proj-123")),
|
||||
("RAILWAY_ENVIRONMENT_ID", None),
|
||||
],
|
||||
Configs::resolve_env_var_project,
|
||||
);
|
||||
let resolved = result.unwrap().expect("should return Some");
|
||||
assert_eq!(resolved.project_id, "proj-123");
|
||||
assert!(resolved.environment_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_var_environment_id_without_project_id_is_rejected() {
|
||||
let result = with_env_vars(
|
||||
&[
|
||||
("RAILWAY_PROJECT_ID", None),
|
||||
("RAILWAY_ENVIRONMENT_ID", Some("env-456")),
|
||||
],
|
||||
Configs::resolve_env_var_project,
|
||||
);
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("RAILWAY_ENVIRONMENT_ID cannot be set without RAILWAY_PROJECT_ID"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,22 +72,24 @@ pub async fn ensure_project_and_environment_exist(
|
|||
bail!(RailwayError::ProjectDeleted);
|
||||
}
|
||||
|
||||
let environment = get_matched_environment(
|
||||
&project,
|
||||
linked_project
|
||||
.environment_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| linked_project.environment.clone()),
|
||||
);
|
||||
// Only validate the environment if one is linked; callers that need an
|
||||
// environment (or accept --environment) resolve and validate it themselves.
|
||||
if let Some(env_id_or_name) = linked_project
|
||||
.environment_name
|
||||
.clone()
|
||||
.or_else(|| linked_project.environment.clone())
|
||||
{
|
||||
let environment = get_matched_environment(&project, env_id_or_name);
|
||||
|
||||
match environment {
|
||||
Ok(environment) => {
|
||||
if environment.deleted_at.is_some() {
|
||||
bail!(RailwayError::EnvironmentDeleted);
|
||||
match environment {
|
||||
Ok(environment) => {
|
||||
if environment.deleted_at.is_some() {
|
||||
bail!(RailwayError::EnvironmentDeleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => bail!(RailwayError::EnvironmentDeleted),
|
||||
};
|
||||
Err(_) => bail!(RailwayError::EnvironmentDeleted),
|
||||
};
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -155,7 +157,10 @@ pub async fn resolve_service_context(
|
|||
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
|
||||
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
|
||||
|
||||
let env = environment_arg.unwrap_or(linked_project.environment.clone());
|
||||
let env = match environment_arg {
|
||||
Some(env) => env,
|
||||
None => linked_project.environment_id()?.to_string(),
|
||||
};
|
||||
let environment_id = get_matched_environment(&project, env)?.id;
|
||||
|
||||
let services = &project.services.edges;
|
||||
|
|
|
|||
Loading…
Reference in a new issue