cli/src/commands/shell.rs
Mahmoud Abdelwahab 3ca7e63637
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>
2026-03-30 11:05:18 +02:00

294 lines
8.8 KiB
Rust

use anyhow::bail;
use std::collections::BTreeMap;
use crate::{
controllers::project::{ensure_project_and_environment_exist, get_project},
controllers::variables::get_service_variables,
errors::RailwayError,
};
use super::*;
/// winapi is only used on windows
#[cfg(target_os = "windows")]
extern crate winapi;
#[cfg(target_os = "windows")]
use winapi::shared::minwindef::DWORD;
#[cfg(target_os = "windows")]
use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
#[cfg(target_os = "windows")]
use winapi::um::tlhelp32::{
CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next, TH32CS_SNAPPROCESS,
};
/// memory management helpers are also only used on windows
#[cfg(target_os = "windows")]
use std::ffi::CStr;
#[cfg(target_os = "windows")]
use std::mem::zeroed;
/// Open a local subshell with Railway variables available
#[derive(Parser)]
pub struct Args {
/// Service to pull variables from (defaults to linked service)
#[clap(short, long)]
service: Option<String>,
/// Open shell without banner
#[clap(long)]
silent: bool,
}
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?;
ensure_project_and_environment_exist(&client, &configs, &linked_project).await?;
let project = get_project(&client, &configs, linked_project.project.clone()).await?;
let mut all_variables = BTreeMap::<String, String>::new();
all_variables.insert("IN_RAILWAY_SHELL".to_owned(), "true".to_owned());
if let Some(service) = args.service {
let service_id = project
.services
.edges
.iter()
.find(|s| s.node.name == service || s.node.id == service)
.ok_or_else(|| RailwayError::ServiceNotFound(service))?;
let mut variables = get_service_variables(
&client,
&configs,
linked_project.project.clone(),
linked_project.environment_id()?.to_string(),
service_id.node.id.clone(),
)
.await?;
all_variables.append(&mut variables);
} else if let Some(ref service) = linked_project.service {
let mut variables = get_service_variables(
&client,
&configs,
linked_project.project.clone(),
linked_project.environment_id()?.to_string(),
service.clone(),
)
.await?;
all_variables.append(&mut variables);
} else {
bail!("No service linked. Please link one with `railway service`");
}
let shell = std::env::var("SHELL").unwrap_or(match std::env::consts::OS {
"windows" => match windows_shell_detection().await {
Some(shell) => shell.to_string(),
None => "cmd".to_string(),
},
_ => "sh".to_string(),
});
let shell_options = match shell.as_str() {
"powershell" => vec!["/nologo"],
"pwsh" => vec!["/nologo"],
"cmd" => vec!["/k"],
_ => vec![],
};
if !args.silent {
println!("Entering subshell with Railway variables available. Type 'exit' to exit.\n");
}
// a bit janky :/
ctrlc::set_handler(move || {
// do nothing, we just want to ignore CTRL+C
// this is for `rails c` and similar REPLs
})?;
tokio::process::Command::new(shell)
.args(shell_options)
.envs(all_variables)
.spawn()
.context("Failed to spawn command")?
.wait()
.await
.context("Failed to wait for command")?;
if !args.silent {
println!("Exited subshell, Railway variables no longer available.");
}
Ok(())
}
#[cfg(target_os = "windows")]
unsafe fn wrapper_fix_recursive(
process_id: DWORD,
recursion: Option<u32>,
) -> Result<(u32, String)> {
// recursive because for some reason it occasionally is more than one level deep
let recursion = recursion.unwrap_or(0);
if recursion > 10 {
// no error, just return nothing and it will default to cmd.
return Ok((0, "".to_string()));
}
let (ppid, ppname) = unsafe {
get_parent_process_info(Some(process_id))
.context("Failed to get parent process info")
.unwrap_or_else(|_| (0, "".to_string()))
};
let triggers = vec!["node.exe", "railway.exe"];
if triggers.contains(&ppname.as_str()) {
wrapper_fix_recursive(ppid, recursion.checked_add(1))
} else {
Ok((ppid, ppname))
}
}
// disable dead code warning for windows_shell_detection
#[allow(dead_code)]
enum WindowsShell {
Cmd,
Powershell,
Powershell7,
NuShell,
ElvSh,
}
impl core::fmt::Display for WindowsShell {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
match *self {
WindowsShell::Powershell => write!(f, "powershell"),
WindowsShell::Cmd => write!(f, "cmd"),
WindowsShell::Powershell7 => write!(f, "pwsh"),
WindowsShell::NuShell => write!(f, "nu"),
WindowsShell::ElvSh => write!(f, "elvish"),
}
}
}
impl std::str::FromStr for WindowsShell {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"cmd" => Ok(WindowsShell::Cmd),
"powershell" => Ok(WindowsShell::Powershell),
"pwsh" => Ok(WindowsShell::Powershell7),
"nu" => Ok(WindowsShell::NuShell),
"elvish" => Ok(WindowsShell::ElvSh),
_ => Ok(WindowsShell::Cmd),
}
}
}
/// https://gist.github.com/mattn/253013/d47b90159cf8ffa4d92448614b748aa1d235ebe4
///
/// defaults to cmd if no parent process is found
///
// How to add new shells:
//
// 1. edit the WindowsShell enum and add the new shell
// 2. edit the impl Display for WindowsShell and add the new shell's stringified name (Powershell7 => "pwsh")
// 3. edit the impl FromStr for WindowsShell and add the new shell's stringified name (pwsh => Powershell7)
#[cfg(target_os = "windows")]
async fn windows_shell_detection() -> Option<WindowsShell> {
let (ppid, mut ppname) = unsafe {
get_parent_process_info(None)
.context("Failed to get parent process info")
.unwrap_or_else(|_| (0, "".to_string()))
};
let triggers = vec!["node.exe", "railway.exe"];
if triggers.contains(&ppname.as_str()) {
(_, ppname) = unsafe {
wrapper_fix_recursive(ppid, None)
.context("Failed to get parent process info")
// acceptable return because if it fails it will default to cmd
.unwrap_or_else(|_| (0, "".to_string()))
}
}
let ppname = ppname.split('.').next().unwrap_or("cmd");
ppname.parse::<WindowsShell>().ok()
}
#[cfg(not(target_os = "windows"))]
async fn windows_shell_detection() -> Option<WindowsShell> {
None
}
/// get the parent process info, translated from
// https://gist.github.com/mattn/253013/d47b90159cf8ffa4d92448614b748aa1d235ebe4
#[cfg(target_os = "windows")]
unsafe fn get_parent_process_info(pid: Option<DWORD>) -> Option<(DWORD, String)> {
let pid = pid.unwrap_or(std::process::id());
let mut pe32: PROCESSENTRY32 = unsafe { zeroed() };
let h_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
let mut ppid = 0;
if h_snapshot == INVALID_HANDLE_VALUE {
return None;
}
pe32.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
if unsafe { Process32First(h_snapshot, &mut pe32) } != 0 {
loop {
if pe32.th32ProcessID == pid {
ppid = pe32.th32ParentProcessID;
break;
}
if unsafe { Process32Next(h_snapshot, &mut pe32) } == 0 {
break;
}
}
}
let mut parent_process_name = None;
if ppid != 0 {
parent_process_name = get_process_name(ppid);
}
unsafe { CloseHandle(h_snapshot) };
parent_process_name.map(|ppname| (ppid, ppname))
}
#[cfg(target_os = "windows")]
unsafe fn get_process_name(pid: DWORD) -> Option<String> {
let mut pe32: PROCESSENTRY32 = unsafe { zeroed() };
let h_snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if h_snapshot == INVALID_HANDLE_VALUE {
return None;
}
pe32.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
if unsafe { Process32First(h_snapshot, &mut pe32) } != 0 {
loop {
if pe32.th32ProcessID == pid {
let process_name_cstr = unsafe { CStr::from_ptr(pe32.szExeFile.as_ptr()) };
let process_name = process_name_cstr.to_string_lossy().into_owned();
unsafe { CloseHandle(h_snapshot) };
return Some(process_name);
}
if unsafe { Process32Next(h_snapshot, &mut pe32) } == 0 {
break;
}
}
}
unsafe { CloseHandle(h_snapshot) };
None
}