Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,151 changes: 1,151 additions & 0 deletions src/commands/code.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod add;
pub mod autoupdate;
pub mod bucket;
pub mod cdn;
pub mod code;
pub mod completion;
pub mod config;
pub mod connect;
Expand Down
61 changes: 42 additions & 19 deletions src/commands/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ impl std::fmt::Display for Choice {
/// explicit `--project`/`--environment` flags → the linked project/environment
/// → an interactive picker (when attached to a TTY) → a helpful error in
/// non-interactive contexts.
async fn resolve_project_and_env(
pub(crate) async fn resolve_project_and_env(
configs: &mut Configs,
client: &reqwest::Client,
project: Option<String>,
Expand Down Expand Up @@ -774,7 +774,7 @@ fn parse_env_file(path: &std::path::Path) -> Result<Vec<Variable>> {
/// scalar, wrapping bare references. Files load first (in order), then flags —
/// so a `--variable` overrides a file entry with the same key. `None` when
/// empty so `skip_serializing_none` omits the field from the mutation input.
fn variables_to_input(
pub(crate) fn variables_to_input(
env_files: &[std::path::PathBuf],
args: &[String],
) -> Result<Option<BTreeMap<String, String>>> {
Expand All @@ -793,18 +793,29 @@ fn variables_to_input(
))
}

/// How `create_and_store` reports the new sandbox.
pub(crate) enum CreateReport {
/// The full `sandbox create` block: id, status, region, connect hints.
Full,
Json,
/// One line — `railway code` wraps the sandbox in its own UX, so the
/// status/region/connect-hints block is noise there.
Quiet,
}

/// Run `sandboxCreate` with the given input, persist the result as the active
/// sandbox (create and fork both retarget `ssh`/`exec` at the new sandbox),
/// and print create-style output.
async fn create_and_store(
/// and print create-style output. Returns the new sandbox's id (`pub(crate)`
/// so `railway code` can reuse the create flow).
pub(crate) async fn create_and_store(
configs: &mut Configs,
client: &reqwest::Client,
project_id: String,
environment_id: String,
input: mutations::sandbox_create::SandboxCreateInput,
json: bool,
report: CreateReport,
forked: bool,
) -> Result<()> {
) -> Result<String> {
let (doing, did, failed) = if forked {
("Forking sandbox", "Forked", "Failed to fork sandbox")
} else {
Expand Down Expand Up @@ -838,18 +849,20 @@ async fn create_and_store(
);
configs.write()?;

if json {
println!("{}", serde_json::to_string_pretty(&sandbox)?);
} else {
println!("✓ {did} sandbox {} (now active)", sandbox.id);
println!(" status: {:?}", sandbox.status);
println!(" region: {}", sandbox.region);
if let Some(idle) = sandbox.idle_timeout_minutes {
println!(" idle timeout: {idle}m");
match report {
CreateReport::Json => println!("{}", serde_json::to_string_pretty(&sandbox)?),
CreateReport::Quiet => println!("✓ {did} sandbox {}", sandbox.id),
CreateReport::Full => {
println!("✓ {did} sandbox {} (now active)", sandbox.id);
println!(" status: {:?}", sandbox.status);
println!(" region: {}", sandbox.region);
if let Some(idle) = sandbox.idle_timeout_minutes {
println!(" idle timeout: {idle}m");
}
println!("\nConnect with:\n railway sandbox ssh");
}
println!("\nConnect with:\n railway sandbox ssh");
}
Ok(())
Ok(sandbox.id)
}

async fn create(
Expand Down Expand Up @@ -907,10 +920,15 @@ async fn create(
project_id,
environment_id,
input,
args.json,
if args.json {
CreateReport::Json
} else {
CreateReport::Full
},
false,
)
.await
.map(|_| ())
}

async fn template(
Expand Down Expand Up @@ -1373,10 +1391,15 @@ async fn fork(
project_id,
environment_id,
input,
args.json,
if args.json {
CreateReport::Json
} else {
CreateReport::Full
},
true,
)
.await
.map(|_| ())
}

async fn list(
Expand Down Expand Up @@ -2039,7 +2062,7 @@ async fn fetch_sandbox_status(
Ok(res.sandbox.map(|s| s.status))
}

fn spawn_heartbeat(
pub(crate) fn spawn_heartbeat(
client: reqwest::Client,
backboard: String,
environment_id: String,
Expand Down
9 changes: 5 additions & 4 deletions src/commands/ssh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ pub(crate) mod tel;

use common::*;

// Re-exported for the `sandbox` command, which reuses the same native SSH
// transport (key registration + `ssh <target>@<env relay host>`).
// Re-exported for the `sandbox` and `code` commands, which reuse the same
// native SSH transport (key registration + `ssh <target>@<env relay host>`).
pub use native::{
DurableResume, PortForward, ensure_ssh_key, get_service_instance_id, run_native_ssh,
run_native_ssh_forward, spawn_native_ssh_forward,
DurableResume, PortForward, ensure_ssh_key, ensure_ssh_key_quiet, get_service_instance_id,
run_native_ssh, run_native_ssh_captured, run_native_ssh_forward, run_native_ssh_with_opts,
spawn_native_ssh_forward,
};

/// Connect to a service via SSH or manage SSH keys
Expand Down
92 changes: 85 additions & 7 deletions src/commands/ssh/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,22 @@ pub async fn get_service_instance_id(
/// CLI doesn't need to distinguish — it passes `workspaceId: null` and
/// the resolver defaults from `ctx.workspace.id` when present.
pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<Option<PathBuf>> {
ensure_ssh_key_impl(client, configs, true).await
}

/// `ensure_ssh_key` without the "Using SSH key from ..." announcement when a
/// registered key is already in place — for flows like `railway code` where
/// ssh is plumbing, not the product. First-run registration still prompts
/// and prints normally.
pub async fn ensure_ssh_key_quiet(client: &Client, configs: &Configs) -> Result<Option<PathBuf>> {
ensure_ssh_key_impl(client, configs, false).await
}

async fn ensure_ssh_key_impl(
client: &Client,
configs: &Configs,
announce: bool,
) -> Result<Option<PathBuf>> {
let local_keys = find_local_ssh_keys().await?;

if local_keys.is_empty() {
Expand All @@ -93,13 +109,15 @@ pub async fn ensure_ssh_key(client: &Client, configs: &Configs) -> Result<Option
});

if let Some(key) = registered_local {
match &key.source {
SshKeySource::File(path) => eprintln!(
"Using SSH key from file {}: {}",
path.display(),
key.key_name()
),
SshKeySource::Agent => eprintln!("Using SSH key from agent: {}", key.key_name()),
if announce {
match &key.source {
SshKeySource::File(path) => eprintln!(
"Using SSH key from file {}: {}",
path.display(),
key.key_name()
),
SshKeySource::Agent => eprintln!("Using SSH key from agent: {}", key.key_name()),
}
}
return Ok(identity_for(key));
}
Expand Down Expand Up @@ -273,11 +291,27 @@ pub fn run_native_ssh(
command: Option<&[String]>,
identity_file: Option<&Path>,
durable: Option<DurableResume<'_>>,
) -> Result<i32> {
run_native_ssh_with_opts(service_instance_id, command, identity_file, durable, &[])
}

/// `run_native_ssh` with extra `ssh` options (each element one argv entry,
/// e.g. `["-o", "ControlMaster=auto"]`). Lets callers opt into connection
/// multiplexing without changing the shared default path.
pub fn run_native_ssh_with_opts(
service_instance_id: &str,
command: Option<&[String]>,
identity_file: Option<&Path>,
durable: Option<DurableResume<'_>>,
extra_opts: &[String],
) -> Result<i32> {
let stdin_tty = std::io::stdin().is_terminal();
let stdout_tty = std::io::stdout().is_terminal();

let (mut ssh_cmd, target) = base_ssh_command(service_instance_id, identity_file);
for opt in extra_opts {
ssh_cmd.arg(opt);
}

if let Some(durable) = durable {
// Both env keys ride a single SetEnv directive: pre-8.7 OpenSSH only
Expand Down Expand Up @@ -476,3 +510,47 @@ fn wait_for_forward_ready(guard: &mut ForwardGuard, forwards: &[PortForward]) ->
sleep(Duration::from_millis(150));
}
}

/// Run a non-interactive command on the target with optional bytes fed to its
/// stdin, and stdout + stderr captured. Built for agent-launcher plumbing
/// (`railway code`): secret payloads (credentials) ride stdin so they never
/// appear in an argv, small reads come back on stdout, and stderr comes back
/// so callers can tell transport failures (host key, relay refusal) apart
/// from remote-command failures instead of showing a bare exit code.
pub fn run_native_ssh_captured(
ssh_target: &str,
command: &str,
identity_file: Option<&Path>,
stdin_payload: Option<&[u8]>,
extra_opts: &[String],
) -> Result<(i32, Vec<u8>, Vec<u8>)> {
use std::io::Write;

let (mut ssh_cmd, target) = base_ssh_command(ssh_target, identity_file);
for opt in extra_opts {
ssh_cmd.arg(opt);
}
ssh_cmd.arg("-T");
ssh_cmd.arg(&target);
ssh_cmd.arg(command);
ssh_cmd.stdin(if stdin_payload.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
ssh_cmd.stdout(Stdio::piped());
ssh_cmd.stderr(Stdio::piped());

let mut child = ssh_cmd.spawn().context("Failed to execute ssh command")?;
if let Some(payload) = stdin_payload {
let mut stdin = child.stdin.take().expect("stdin was piped");
stdin.write_all(payload)?;
// Drop closes the pipe so the remote `cat` sees EOF.
}
let output = child.wait_with_output()?;
Ok((
output.status.code().unwrap_or(1),
output.stdout,
output.stderr,
))
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ commands!(
autoupdate,
bucket,
cdn,
code,
completion,
config,
connect,
Expand Down
11 changes: 11 additions & 0 deletions src/util/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ pub fn prompt_text(message: &str) -> Result<String> {
.context("Failed to prompt for options")
}

/// Prompt for a secret (token, key): input is masked as it's typed and never
/// echoed back, with no confirmation round-trip.
pub fn prompt_secret(message: &str) -> Result<String> {
inquire::Password::new(message)
.with_render_config(Configs::get_render_config())
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.without_confirmation()
.prompt()
.context("Failed to prompt for secret")
}

pub fn prompt_u64_with_placeholder_and_validation_and_cancel(
message: &str,
placeholder: &str,
Expand Down
Loading