From a77bcfa62a5c8362e7bfbc5b60f6be49d4b9c67b Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Wed, 27 May 2026 13:34:58 +0200 Subject: [PATCH 01/40] add iac sync command --- src/commands/mod.rs | 1 + src/commands/sync.rs | 308 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 + 3 files changed, 311 insertions(+) create mode 100644 src/commands/sync.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e1117435c..9ae3a7b15 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -37,6 +37,7 @@ pub mod shell; pub mod ssh; pub mod starship; pub mod status; +pub mod sync; pub mod unlink; pub mod up; pub mod upgrade; diff --git a/src/commands/sync.rs b/src/commands/sync.rs new file mode 100644 index 000000000..f18c0b53e --- /dev/null +++ b/src/commands/sync.rs @@ -0,0 +1,308 @@ +use std::{env, path::PathBuf, process::Stdio}; + +use serde::Deserialize; +use serde_json::Value; +use tokio::{io::AsyncWriteExt, process::Command}; + +use super::*; + +/// Preview or stage Railway IaC changes from .railway/railway.ts +#[derive(Parser)] +pub struct Args { + /// Path to the Railway IaC file. Defaults to nearest .railway/railway.ts resolved by the runner. + #[clap(long)] + file: Option, + + /// Stage the proposed ChangeSet in Backboard. + #[clap(long)] + stage: bool, + + /// Output raw runner JSON. + #[clap(long)] + json: bool, + + /// Confirm destructive staged changes. + #[clap(long)] + yes: bool, + + /// Ask Backboard to decrypt variables while planning, when authorized. + #[clap(long)] + decrypt_variables: bool, + + /// Include generated graph TypeScript types in runner output. + #[clap(long)] + include_types: bool, + + /// Override linked project id. Primarily for local alpha testing. + #[clap(long)] + project_id: Option, + + /// Override linked environment id. Primarily for local alpha testing. + #[clap(long)] + environment_id: Option, + + /// Path to the TypeScript IaC runner binary. Defaults to RAILWAY_IAC_TS_BIN or railway-iac-ts. + #[clap(long)] + runner: Option, +} + +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct RunnerResponse { + ok: bool, + command: String, + file: String, + current_environment: Option, + change_set: Option, + diff: Option, + diagnostics: Vec, + staged_patch: Option, +} + +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct CurrentEnvironment { + project_id: Option, + environment_id: String, + environment_name: Option, +} + +#[derive(Deserialize, serde::Serialize)] +struct ChangeSet { + changes: Vec, +} + +#[derive(Deserialize, serde::Serialize)] +struct Change { + summary: Option, + severity: Option, + kind: Option, +} + +#[derive(Deserialize, serde::Serialize)] +struct Diagnostic { + severity: String, + path: String, + message: String, +} + +#[derive(Deserialize, serde::Serialize)] +struct StagedPatch { + id: String, + #[allow(dead_code)] + patch: Option, +} + +pub async fn command(args: Args) -> Result<()> { + let configs = Configs::new()?; + let linked_project = configs.get_linked_project().await?; + let token = get_runner_token(&configs)?; + let command = if args.stage { "stage" } else { "plan" }; + + if args.stage && !args.yes { + let preview = invoke_runner(&args, &configs, &linked_project, &token, "plan").await?; + if has_destructive_changes(&preview) { + bail!("Plan contains destructive changes. Re-run with --yes to stage."); + } + } + + let output = invoke_runner(&args, &configs, &linked_project, &token, command).await?; + + if args.json { + println!("{}", serde_json::to_string_pretty(&output)?); + if !output.ok { + bail!("IaC runner returned diagnostics"); + } + return Ok(()); + } + + print_response(&output); + if !output.ok { + bail!("IaC runner returned diagnostics"); + } + + Ok(()) +} + +fn get_runner_token(configs: &Configs) -> Result { + if Configs::get_railway_token().is_some() { + bail!("railway sync currently requires a user/API token; project tokens are not supported by the TypeScript IaC runner yet") + } + + configs + .get_railway_auth_token() + .context("Not authenticated. Run `railway login` or set RAILWAY_API_TOKEN.") +} + +async fn invoke_runner( + args: &Args, + configs: &Configs, + linked_project: &LinkedProject, + token: &str, + command: &str, +) -> Result { + let runner = args + .runner + .clone() + .or_else(|| env::var("RAILWAY_IAC_TS_BIN").ok()) + .unwrap_or_else(|| "railway-iac-ts".to_string()); + + let request = serde_json::json!({ + "command": command, + "file": args.file.as_ref().map(|path| path.to_string_lossy().to_string()), + "includeTypes": args.include_types, + "pretty": false, + "backboard": { + "endpoint": configs.get_backboard(), + "token": token, + "projectId": args.project_id.as_deref().unwrap_or(&linked_project.project), + "environmentId": args.environment_id.as_deref().unwrap_or(&linked_project.environment), + "decryptVariables": args.decrypt_variables, + "merge": true + } + }); + + let mut command = Command::new(&runner); + if let Some(runner_cwd) = runner_cwd(&runner) { + command.current_dir(runner_cwd); + } + if matches!(Configs::get_environment_id(), Environment::Dev) { + command.env("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + } + + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to spawn IaC runner `{runner}`. Install/link the railway TypeScript SDK or pass --runner."))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(request.to_string().as_bytes()).await?; + } + + let output = child.wait_with_output().await?; + let stdout = String::from_utf8(output.stdout).context("Runner stdout was not valid UTF-8")?; + let stderr = String::from_utf8(output.stderr).context("Runner stderr was not valid UTF-8")?; + + let response: RunnerResponse = serde_json::from_str(&stdout).with_context(|| { + format!("IaC runner returned non-JSON output.\nstdout:\n{stdout}\nstderr:\n{stderr}") + })?; + + Ok(response) +} + +fn runner_cwd(runner: &str) -> Option { + let path = PathBuf::from(runner); + if path.file_name()?.to_str()? != "bin.js" { + return None; + } + let iac_dir = path.parent()?; + if iac_dir.file_name()?.to_str()? != "iac" { + return None; + } + let dist_dir = iac_dir.parent()?; + if dist_dir.file_name()?.to_str()? != "dist" { + return None; + } + dist_dir.parent().map(|path| path.to_path_buf()) +} + +fn has_destructive_changes(response: &RunnerResponse) -> bool { + response + .change_set + .as_ref() + .map(|change_set| { + change_set + .changes + .iter() + .any(|change| change.severity.as_deref() == Some("destructive")) + }) + .unwrap_or(false) +} + +fn print_response(response: &RunnerResponse) { + println!("{}", "Railway IaC sync".bold()); + println!("runner: {}", response.command); + println!("file: {}", response.file); + + if let Some(environment) = &response.current_environment { + println!( + "project: {}", + environment.project_id.as_deref().unwrap_or("(unknown)") + ); + println!( + "environment: {}", + environment + .environment_name + .as_deref() + .unwrap_or(&environment.environment_id) + ); + } + println!(); + + for diagnostic in &response.diagnostics { + let text = if diagnostic.path.is_empty() { + format!("{}: {}", diagnostic.severity, diagnostic.message) + } else { + format!( + "{}: {}: {}", + diagnostic.severity, diagnostic.path, diagnostic.message + ) + }; + if diagnostic.severity == "error" { + println!("{}", text.red()); + } else { + println!("{}", text.yellow()); + } + } + + if !response.ok { + return; + } + + let changes = response + .change_set + .as_ref() + .map(|change_set| change_set.changes.as_slice()) + .unwrap_or(&[]); + + if changes.is_empty() { + println!("{}", "No changes.".green()); + } else { + println!("{}", "ChangeSet".bold()); + if let Some(diff) = &response.diff { + println!("{diff}"); + } else { + for change in changes { + println!( + "{}", + change + .summary + .as_deref() + .or(change.kind.as_deref()) + .unwrap_or("change") + ); + } + } + + let destructive = changes + .iter() + .filter(|change| change.severity.as_deref() == Some("destructive")) + .count(); + if destructive > 0 { + println!("{}", format!("{destructive} destructive change(s).").red()); + } + } + + if let Some(staged_patch) = &response.staged_patch { + println!(); + println!( + "{}", + format!("Staged Backboard patch: {}", staged_patch.id).green() + ); + } else { + println!(); + println!("Run with {} to stage the proposed ChangeSet.", "--stage".cyan()); + } +} diff --git a/src/main.rs b/src/main.rs index dadf609b8..2e89abe02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ commands!( ssh, starship, status, + sync, unlink, up, upgrade, @@ -204,6 +205,7 @@ mod cli_tests { assert_subcommand(&["link"], "link"); assert_subcommand(&["up"], "up"); assert_subcommand(&["redeploy"], "redeploy"); + assert_subcommand(&["sync"], "sync"); } #[test] From e837a5b74bd726832196d54ae124f64e8bb79b2c Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Mon, 1 Jun 2026 17:40:05 +0200 Subject: [PATCH 02/40] Add Railway config commands --- assets/railway-config/README.md | 37 ++ assets/railway-config/SKILL.md | 133 +++++++ src/commands/config_command.rs | 609 ++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/sync.rs | 344 ++++++++++++++---- src/commands/up.rs | 130 ++++++- src/macros.rs | 5 +- src/main.rs | 1 + 8 files changed, 1182 insertions(+), 78 deletions(-) create mode 100644 assets/railway-config/README.md create mode 100644 assets/railway-config/SKILL.md create mode 100644 src/commands/config_command.rs diff --git a/assets/railway-config/README.md b/assets/railway-config/README.md new file mode 100644 index 000000000..a0a1df298 --- /dev/null +++ b/assets/railway-config/README.md @@ -0,0 +1,37 @@ +# Railway configuration + +This project contains a Railway configuration file: + +```txt +.railway/railway.ts +``` + +Use it to describe the desired shape of this Railway project: services, databases, buckets, volumes, domains, and environment variables. + +## Commands + +Preview changes: + +```bash +railway config plan +``` + +Stage changes for review: + +```bash +railway config stage +``` + +Apply changes: + +```bash +railway config apply +``` + +Deploy code: + +```bash +railway up +``` + +If `.railway/railway.ts` has pending changes, `railway up` may ask to apply them before deploying. diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md new file mode 100644 index 000000000..50041f1e9 --- /dev/null +++ b/assets/railway-config/SKILL.md @@ -0,0 +1,133 @@ +# Railway configuration skill + +Use this skill when editing this repository's Railway configuration. + +The source of desired Railway project state is: + +```txt +.railway/railway.ts +``` + +## Rules + +1. Express Railway product intent, not internal API details. +2. Do not write Railway UUIDs into `.railway/railway.ts`. +3. Do not write `EnvironmentConfigPatch`, `ServiceInstance`, or Backboard internals into source. +4. Prefer helpers like `service()`, `postgres()`, `redis()`, `bucket()`, and `volume()`. +5. Keep secrets out of source. Prefer references and Railway-managed variables. +6. After editing `.railway/railway.ts`, run `railway config plan`. +7. Do not run `railway config apply` unless the user asks. + +## Commands + +Preview: + +```bash +railway config plan +``` + +Stage: + +```bash +railway config stage +``` + +Apply: + +```bash +railway config apply +``` + +Machine-readable preview: + +```bash +railway config plan --json +``` + +## Authoring + +Use the Railway configuration helpers: + +```ts +import { + bucket, + defineRailway, + github, + image, + mongo, + mysql, + postgres, + project, + redis, + service, + volume, +} from "railway/iac"; +``` + +Minimal service: + +```ts +const web = service("web", { + build: "pnpm install --frozen-lockfile && pnpm build", + start: "pnpm start", + env: { + NODE_ENV: "production", + }, +}); +``` + +Database reference: + +```ts +const db = postgres("postgres"); + +const web = service("web", { + env: { + DATABASE_URL: db.url(), + PGHOST: db.env.PGHOST, + }, +}); +``` + +Service-to-service reference: + +```ts +const api = service("api", { + env: { + INTERNAL_TOKEN: "replace-me", + }, +}); + +const web = service("web", { + env: { + API_TOKEN: api.env.INTERNAL_TOKEN, + API_HOST: api.env.RAILWAY_PRIVATE_DOMAIN, + }, +}); +``` + +Custom domain: + +```ts +const web = service("web", { + domains: ["app.example.com"], +}); +``` + +Project shape: + +```ts +export default defineRailway(() => { + const db = postgres("postgres"); + const web = service("web", { + env: { + DATABASE_URL: db.url(), + }, + }); + + return project("my-app", { + environments: ["production"], + services: [db, web], + }); +}); +``` diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs new file mode 100644 index 000000000..3ecfc0f8a --- /dev/null +++ b/src/commands/config_command.rs @@ -0,0 +1,609 @@ +use std::{fs, path::{Path, PathBuf}}; + +use is_terminal::IsTerminal; + +use crate::util::prompt::prompt_select; + +use super::*; + +/// Manage Railway configuration from .railway/railway.ts +#[derive(Parser)] +pub struct Args { + #[clap(subcommand)] + command: Command, +} + +#[derive(Parser)] +enum Command { + /// Preview project configuration changes + Plan(SharedArgs), + + /// Stage project configuration changes for review + Stage(SharedArgs), + + /// Apply project configuration changes + Apply(SharedArgs), + + /// Create a Railway configuration file for this project + Init(InitArgs), + + /// Pull current Railway project configuration into code + Pull(PullArgs), +} + +#[derive(Parser, Clone)] +struct SharedArgs { + /// Path to the Railway configuration file. Defaults to nearest .railway/railway.ts. + #[clap(long)] + file: Option, + + /// Output raw runner JSON. + #[clap(long)] + json: bool, + + /// Confirm prompts and proceed non-interactively. + #[clap(long)] + yes: bool, + + /// Ask Railway to decrypt variables while planning, when authorized. + #[clap(long)] + decrypt_variables: bool, + + /// Include generated graph TypeScript types in runner output. + #[clap(long)] + include_types: bool, + + /// Path to the TypeScript configuration runner. Defaults to RAILWAY_IAC_TS_BIN or railway-iac-ts. + #[clap(long)] + runner: Option, + + /// Show full change details. + #[clap(long, alias = "full")] + verbose: bool, +} + +#[derive(Clone, Copy)] +enum InitMode { + GenerateFromRepo, + ImportFromRailway, + MinimalFile, +} + +impl std::fmt::Display for InitMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InitMode::GenerateFromRepo => write!(f, "Generate from this repo"), + InitMode::ImportFromRailway => write!(f, "Import from linked Railway project"), + InitMode::MinimalFile => write!(f, "Create minimal file"), + } + } +} + +#[derive(Parser)] +struct InitArgs { + /// Overwrite an existing .railway/railway.ts file. + #[clap(long)] + force: bool, +} + +#[derive(Parser)] +struct PullArgs { + /// Overwrite an existing .railway/railway.ts file. + #[clap(long)] + force: bool, + + /// Output raw imported graph JSON instead of writing files. + #[clap(long)] + json: bool, + + /// Path to the TypeScript configuration runner. Defaults to RAILWAY_IAC_TS_BIN or railway-iac-ts. + #[clap(long)] + runner: Option, + + /// Ask an agent to turn imported state into idiomatic railway.ts code. + #[clap(long)] + agent: bool, +} + +pub async fn command(args: Args) -> Result<()> { + match args.command { + Command::Plan(args) => run_sync(args, false, false).await, + Command::Stage(args) => run_sync(args, true, false).await, + Command::Apply(args) => run_sync(args, false, true).await, + Command::Init(args) => init_config(args).await, + Command::Pull(args) => pull_config(args).await, + } +} + +async fn init_config(args: InitArgs) -> Result<()> { + let cwd = std::env::current_dir().context("Unable to get current directory")?; + let project_name = cwd + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("railway-project") + .to_string(); + + let railway_dir = cwd.join(".railway"); + let railway_file = railway_dir.join("railway.ts"); + let readme_file = railway_dir.join("README.md"); + let skill_dir = cwd.join(".skills").join("railway-config"); + let skill_file = skill_dir.join("SKILL.md"); + + create_parent(&railway_file)?; + create_parent(&skill_file)?; + + let init_mode = if railway_file.exists() || !std::io::stdout().is_terminal() { + InitMode::GenerateFromRepo + } else { + prompt_select( + "How should Railway start your configuration?", + vec![ + InitMode::GenerateFromRepo, + InitMode::ImportFromRailway, + InitMode::MinimalFile, + ], + )? + }; + + match init_mode { + InitMode::GenerateFromRepo => write_new(&railway_file, &railway_ts_from_repo(&cwd, &project_name), args.force)?, + InitMode::ImportFromRailway => write_pulled_config(&railway_file, args.force, None).await?, + InitMode::MinimalFile => write_new(&railway_file, &railway_ts(&project_name), args.force)?, + } + write_new(&readme_file, include_str!("../../assets/railway-config/README.md"), args.force)?; + let wrote_skill = write_asset_if_missing(&skill_file, include_str!("../../assets/railway-config/SKILL.md"))?; + + println!("{}", "Railway configuration initialized".green().bold()); + println!("{} {}", match init_mode { InitMode::ImportFromRailway => "Imported", _ => "Created" }.dimmed(), railway_file.display().to_string().cyan()); + println!("{} {}", "Created".dimmed(), readme_file.display().to_string().cyan()); + if wrote_skill { + println!("{} {}", "Created".dimmed(), skill_file.display().to_string().cyan()); + } + println!(); + println!("{}", "Next steps".bold()); + println!(" {} Edit {} to describe your Railway project.", "•".cyan(), ".railway/railway.ts".cyan()); + println!(" {} Run {} to preview changes.", "•".cyan(), "railway config plan".cyan()); + println!(" {} Run {} to apply them.", "•".cyan(), "railway config apply".cyan()); + + Ok(()) +} + +fn create_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + Ok(()) +} + +fn write_asset_if_missing(path: &Path, contents: &str) -> Result { + if path.exists() { + return Ok(false); + } + fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(true) +} + +fn write_new(path: &Path, contents: &str, force: bool) -> Result<()> { + if path.exists() && !force { + bail!("{} already exists. Re-run with --force to overwrite it.", path.display()); + } + fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display())) +} + +async fn pull_config(args: PullArgs) -> Result<()> { + let cwd = std::env::current_dir().context("Unable to get current directory")?; + let railway_file = cwd.join(".railway").join("railway.ts"); + + if args.json { + let graph = load_current_graph(args.runner).await?; + println!("{}", serde_json::to_string_pretty(&graph)?); + return Ok(()); + } + + create_parent(&railway_file)?; + write_pulled_config(&railway_file, args.force, args.runner).await?; + + println!("{}", "Railway configuration imported".green().bold()); + println!("{} {}", "Updated".dimmed(), railway_file.display().to_string().cyan()); + println!(); + println!("{}", "Next steps".bold()); + println!(" {} Review {} and remove anything you do not want managed from code.", "•".cyan(), ".railway/railway.ts".cyan()); + println!(" {} Run {} to verify it matches Railway.", "•".cyan(), "railway config plan".cyan()); + if args.agent { + println!(" {} Ask your agent to clean this import into idiomatic Railway configuration.", "•".cyan()); + } + + Ok(()) +} + +async fn write_pulled_config(path: &Path, force: bool, runner: Option) -> Result<()> { + let graph = load_current_graph(runner).await?; + write_new(path, &render_graph_as_railway_ts(&graph), force) +} + +async fn load_current_graph(runner: Option) -> Result { + let temp_dir = std::env::temp_dir().join(format!("railway-config-pull-{}", std::process::id())); + fs::create_dir_all(&temp_dir).context("Failed to create temporary Railway config directory")?; + let temp_file = temp_dir.join("railway.ts"); + fs::write(&temp_file, railway_ts("import-placeholder")) + .context("Failed to write temporary Railway config")?; + + let args = crate::commands::sync::Args { + file: Some(temp_file.clone()), + stage: false, + json: true, + yes: false, + decrypt_variables: false, + include_types: false, + runner, + verbose: false, + }; + let response = crate::commands::sync::run(&args, "plan").await?; + let _ = fs::remove_file(temp_file); + let _ = fs::remove_dir(temp_dir); + + if !response.ok { + bail!("Could not import Railway configuration because planning returned diagnostics."); + } + + response.current_graph.context("Railway did not return current project state") +} + +fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> String { + let mut imports = vec!["defineRailway", "project", "service"]; + if graph.resources.iter().any(|resource| resource.r#type == "bucket") { imports.push("bucket"); } + if graph.resources.iter().any(|resource| resource.source.as_ref().and_then(|source| source.get("repo")).is_some()) { imports.push("github"); } + if graph.resources.iter().any(|resource| resource.source.as_ref().and_then(|source| source.get("image")).is_some() && resource.r#type == "service") { imports.push("image"); } + if graph.resources.iter().any(|resource| resource.variables.as_ref().is_some_and(|vars| vars.values().any(|value| value.get("type").and_then(|value| value.as_str()) == Some("preserve")))) { imports.push("preserve"); } + if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("postgres")) { imports.push("postgres"); } + if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("redis")) { imports.push("redis"); } + if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("mysql")) { imports.push("mysql"); } + if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("mongo")) { imports.push("mongo"); } + imports.sort(); + imports.dedup(); + + let mut out = format!("import {{ {} }} from \"railway/iac\";\n\n", imports.join(", ")); + out.push_str("export default defineRailway(() => {\n"); + + let mut names = Vec::new(); + let import_names: std::collections::HashSet<&str> = imports.iter().copied().collect(); + for resource in &graph.resources { + let var_name = unique_resource_ident(&resource.name, &resource.r#type, &import_names, &names); + match resource.r#type.as_str() { + "database" => { + let helper = match resource.engine.as_deref() { + Some("postgres") => "postgres", + Some("redis") => "redis", + Some("mysql") => "mysql", + Some("mongo") => "mongo", + _ => "service", + }; + if helper == "service" { + out.push_str(&format!(" const {var_name} = service(\"{}\");\n", resource.name)); + } else { + out.push_str(&format!(" const {var_name} = {helper}(\"{}\");\n", resource.name)); + } + names.push(var_name); + } + "service" => { + out.push_str(&format!(" const {var_name} = service(\"{}\"", resource.name)); + let body = render_service_body(resource); + if body.is_empty() { + out.push_str(");\n"); + } else { + out.push_str(&format!(", {body});\n")); + } + names.push(var_name); + } + "bucket" => { + let config = resource.config.as_ref().map(ts_value).unwrap_or_default(); + if config.is_empty() { + out.push_str(&format!(" const {var_name} = bucket(\"{}\");\n", resource.name)); + } else { + out.push_str(&format!(" const {var_name} = bucket(\"{}\", {config});\n", resource.name)); + } + names.push(var_name); + } + _ => {} + } + } + + out.push_str("\n return project(\"imported-project\", {\n"); + out.push_str(" environments: [\"production\"],\n"); + out.push_str(&format!(" services: [{}],\n", names.join(", "))); + out.push_str(" });\n"); + out.push_str("});\n"); + out +} + +fn render_service_body(resource: &crate::commands::sync::DesiredResource) -> String { + let mut lines = Vec::new(); + if let Some(source) = &resource.source { + if let Some(repo) = source.get("repo").and_then(|value| value.as_str()) { + let mut args = format!("{:?}", repo); + if let Some(branch) = source.get("branch").and_then(|value| value.as_str()) { + args.push_str(&format!(", {{ branch: {:?} }}", branch)); + } + lines.push(format!(" source: github({args}),")); + } else if let Some(image_name) = source.get("image").and_then(|value| value.as_str()) { + lines.push(format!(" source: image({:?}),", image_name)); + } + } + render_build(resource.build.as_ref(), &mut lines); + render_deploy(resource.deploy.as_ref(), &mut lines); + render_networking(resource.networking.as_ref(), &mut lines); + if let Some(vars) = &resource.variables { + if !vars.is_empty() { + lines.push(" env: {".to_string()); + for (key, value) in vars { + if value.get("type").and_then(|value| value.as_str()) == Some("preserve") { + lines.push(format!(" {key}: preserve(),")); + } else if let Some(literal) = value.get("value").and_then(|value| value.as_str()) { + lines.push(format!(" {key}: {:?},", literal)); + } else if let Some(output) = value.get("output").and_then(|value| value.as_str()) { + lines.push(format!(" {key}: \"${{{{{output}}}}}\",")); + } + } + lines.push(" },".to_string()); + } + } + if lines.is_empty() { return String::new(); } + format!("{{\n{}\n }}", lines.join("\n")) +} + +fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { + let Some(build) = build else { return; }; + if let Some(command) = build.get("buildCommand").and_then(|value| value.as_str()) { + lines.push(format!(" build: {:?},", command)); + return; + } + lines.push(format!(" build: {},", ts_value(build))); +} + +fn render_deploy(deploy: Option<&serde_json::Value>, lines: &mut Vec) { + let Some(deploy) = deploy.and_then(|value| value.as_object()) else { return; }; + let mut remaining = deploy.clone(); + + if let Some(start) = remaining.remove("startCommand").and_then(|value| value.as_str().map(ToOwned::to_owned)) { + lines.push(format!(" start: {:?},", start)); + } + if let Some(healthcheck) = remaining.remove("healthcheckPath").and_then(|value| value.as_str().map(ToOwned::to_owned)) { + lines.push(format!(" healthcheck: {:?},", healthcheck)); + } + if let Some(timeout) = remaining.remove("healthcheckTimeout") { + lines.push(format!(" healthcheckTimeout: {},", ts_value(&timeout))); + } + if let Some(regions) = remaining.remove("multiRegionConfig") { + lines.push(format!(" regions: {},", render_regions(®ions))); + } + + if remaining.get("ipv6EgressEnabled").and_then(|value| value.as_bool()) == Some(false) { + remaining.remove("ipv6EgressEnabled"); + } + if remaining.get("runtime").and_then(|value| value.as_str()) == Some("V2") { + remaining.remove("runtime"); + } + if remaining.get("useLegacyStacker").and_then(|value| value.as_bool()) == Some(false) { + remaining.remove("useLegacyStacker"); + } + + if !remaining.is_empty() { + lines.push(format!(" deploy: {},", ts_value(&serde_json::Value::Object(remaining)))); + } +} + +fn render_regions(value: &serde_json::Value) -> String { + let Some(regions) = value.as_object() else { return ts_value(value); }; + let rendered = regions.iter().map(|(region, config)| { + let replicas = config.get("numReplicas").and_then(|value| value.as_u64()); + let stacker = config.get("stackerAssignment").and_then(|value| value.as_str()); + let value = match (replicas, stacker) { + (Some(replicas), None) => replicas.to_string(), + _ => { + let mut parts = Vec::new(); + if let Some(replicas) = replicas { parts.push(format!("replicas: {replicas}")); } + if let Some(stacker) = stacker { parts.push(format!("stacker: {:?}", stacker)); } + format!("{{ {} }}", parts.join(", ")) + } + }; + format!("{:?}: {value}", region) + }).collect::>().join(", "); + format!("{{ {rendered} }}") +} + +fn render_networking(networking: Option<&serde_json::Value>, lines: &mut Vec) { + let Some(networking) = networking.and_then(|value| value.as_object()) else { return; }; + let mut remaining = networking.clone(); + + remaining.remove("serviceDomains"); + + if let Some(custom_domains) = remaining.remove("customDomains") { + if let Some(domains) = custom_domains.as_object() { + let rendered = domains.iter().map(|(domain, config)| { + let port = config.get("port").and_then(|value| value.as_u64()); + match port { + Some(8080) | None => format!("{:?}", domain), + Some(port) => format!("{{ domain: {:?}, port: {port} }}", domain), + } + }).collect::>().join(", "); + lines.push(format!(" domains: [{rendered}],")); + } + } + + if !remaining.is_empty() { + lines.push(format!(" networking: {},", ts_value(&serde_json::Value::Object(remaining)))); + } +} + +fn ts_value(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Object(object) => { + if object.is_empty() { + return "{}".to_string(); + } + let fields = object + .iter() + .map(|(key, value)| format!("{}: {}", ts_key(key), ts_value(value))) + .collect::>() + .join(", "); + format!("{{ {fields} }}") + } + serde_json::Value::Array(values) => format!("[{}]", values.iter().map(ts_value).collect::>().join(", ")), + serde_json::Value::String(value) => format!("{:?}", value), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Null => "null".to_string(), + } +} + +fn ts_key(key: &str) -> String { + if key.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') + && key.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + key.to_string() + } else { + format!("{:?}", key) + } +} + +fn unique_resource_ident(name: &str, resource_type: &str, reserved: &std::collections::HashSet<&str>, used: &[String]) -> String { + let mut candidate = sanitize_ident(name); + if candidate.is_empty() || reserved.contains(candidate.as_str()) { + candidate = match resource_type { + "database" => format!("{}Database", candidate), + "service" => format!("{}Service", candidate), + _ => format!("{}Resource", candidate), + }; + } + if candidate.is_empty() || candidate == "Database" || candidate == "Service" || candidate == "Resource" { + candidate = "resource".to_string(); + } + let base = candidate.clone(); + let mut suffix = 2; + while used.iter().any(|name| name == &candidate) || reserved.contains(candidate.as_str()) { + candidate = format!("{base}{suffix}"); + suffix += 1; + } + candidate +} + +fn sanitize_ident(name: &str) -> String { + let mut out = String::new(); + let mut capitalize_next = false; + for (idx, ch) in name.chars().enumerate() { + if ch == '-' || ch == ' ' || ch == '.' { + capitalize_next = true; + continue; + } + if idx == 0 && !(ch.is_ascii_alphabetic() || ch == '_') { + out.push('_'); + } + if ch.is_ascii_alphanumeric() || ch == '_' { + if capitalize_next { + out.push(ch.to_ascii_uppercase()); + capitalize_next = false; + } else { + out.push(ch); + } + } + } + out +} + +fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { + let package_json = cwd.join("package.json"); + if !package_json.exists() { + return railway_ts(project_name); + } + + let package = fs::read_to_string(package_json) + .ok() + .and_then(|contents| serde_json::from_str::(&contents).ok()) + .unwrap_or_default(); + let scripts = package.get("scripts").and_then(|scripts| scripts.as_object()); + let package_manager = detect_package_manager(cwd); + let install = match package_manager.as_str() { + "bun" => "bun install --frozen-lockfile", + "pnpm" => "pnpm install --frozen-lockfile", + "yarn" => "yarn install --frozen-lockfile", + _ => "npm ci", + }; + let build = scripts + .and_then(|scripts| scripts.get("build")) + .and_then(|value| value.as_str()) + .map(|_| format!("{install} && {package_manager} run build")); + let start = if scripts.and_then(|scripts| scripts.get("start")).is_some() { + Some(format!("{package_manager} run start")) + } else if cwd.join("src/index.ts").exists() && package_manager == "bun" { + Some("bun src/index.ts".to_string()) + } else if cwd.join("index.js").exists() { + Some("node index.js".to_string()) + } else { + None + }; + + let mut out = "import { defineRailway, project, service } from \"railway/iac\";\n\n".to_string(); + out.push_str("export default defineRailway(() => {\n"); + out.push_str(" const web = service(\"web\", {\n"); + if let Some(build) = build { + out.push_str(&format!(" build: {:?},\n", build)); + } + if let Some(start) = start { + out.push_str(&format!(" start: {:?},\n", start)); + } + out.push_str(" env: {\n NODE_ENV: \"production\",\n },\n"); + out.push_str(" });\n\n"); + out.push_str(&format!(" return project(\"{project_name}\", {{\n")); + out.push_str(" environments: [\"production\"],\n services: [web],\n });\n});\n"); + out +} + +fn detect_package_manager(cwd: &Path) -> String { + if cwd.join("bun.lock").exists() || cwd.join("bun.lockb").exists() { + "bun".to_string() + } else if cwd.join("pnpm-lock.yaml").exists() { + "pnpm".to_string() + } else if cwd.join("yarn.lock").exists() { + "yarn".to_string() + } else { + "npm".to_string() + } +} + +fn railway_ts(project_name: &str) -> String { + format!( + r#"import {{ defineRailway, project, service }} from "railway/iac"; + +export default defineRailway(() => {{ + const web = service("web", {{ + // Add build/start commands when Railway cannot infer them. + // build: "pnpm install --frozen-lockfile && pnpm build", + // start: "pnpm start", + env: {{ + NODE_ENV: "production", + }}, + }}); + + return project("{project_name}", {{ + environments: ["production"], + services: [web], + }}); +}}); +"# + ) +} + +async fn run_sync(args: SharedArgs, stage: bool, apply: bool) -> Result<()> { + crate::commands::sync::command(crate::commands::sync::Args { + file: args.file, + stage, + json: args.json, + yes: apply || args.yes, + decrypt_variables: args.decrypt_variables, + include_types: args.include_types, + runner: args.runner, + verbose: args.verbose, + }) + .await +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 9ae3a7b15..101a02e34 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,6 +10,7 @@ pub fn get_dynamic_args(cmd: clap::Command) -> clap::Command { pub mod add; pub mod completion; +pub mod config_command; pub mod connect; pub mod delete; pub mod deploy; diff --git a/src/commands/sync.rs b/src/commands/sync.rs index f18c0b53e..4a8b32a07 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -1,9 +1,13 @@ use std::{env, path::PathBuf, process::Stdio}; +use is_terminal::IsTerminal; + use serde::Deserialize; use serde_json::Value; use tokio::{io::AsyncWriteExt, process::Command}; +use crate::util::progress::{create_spinner_if, fail_spinner, success_spinner}; + use super::*; /// Preview or stage Railway IaC changes from .railway/railway.ts @@ -11,52 +15,53 @@ use super::*; pub struct Args { /// Path to the Railway IaC file. Defaults to nearest .railway/railway.ts resolved by the runner. #[clap(long)] - file: Option, + pub(super) file: Option, /// Stage the proposed ChangeSet in Backboard. #[clap(long)] - stage: bool, + pub(super) stage: bool, /// Output raw runner JSON. #[clap(long)] - json: bool, + pub(super) json: bool, /// Confirm destructive staged changes. #[clap(long)] - yes: bool, + pub(super) yes: bool, /// Ask Backboard to decrypt variables while planning, when authorized. #[clap(long)] - decrypt_variables: bool, + pub(super) decrypt_variables: bool, /// Include generated graph TypeScript types in runner output. #[clap(long)] - include_types: bool, - - /// Override linked project id. Primarily for local alpha testing. - #[clap(long)] - project_id: Option, - - /// Override linked environment id. Primarily for local alpha testing. - #[clap(long)] - environment_id: Option, + pub(super) include_types: bool, /// Path to the TypeScript IaC runner binary. Defaults to RAILWAY_IAC_TS_BIN or railway-iac-ts. #[clap(long)] - runner: Option, + pub(super) runner: Option, + + /// Show full change details. + #[clap(long, alias = "full")] + pub(super) verbose: bool, } #[derive(Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] -struct RunnerResponse { - ok: bool, +pub(super) struct RunnerResponse { + pub(super) ok: bool, command: String, file: String, current_environment: Option, change_set: Option, diff: Option, diagnostics: Vec, + pub(super) current_graph: Option, + pub(super) desired_graph: Option, staged_patch: Option, + apply_result: Option, + deployment_id: Option, + staged_patch_id: Option, } #[derive(Deserialize, serde::Serialize)] @@ -77,6 +82,7 @@ struct Change { summary: Option, severity: Option, kind: Option, + details: Option>, } #[derive(Deserialize, serde::Serialize)] @@ -86,6 +92,47 @@ struct Diagnostic { message: String, } +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ChangeSetApplyResult { + id: String, + status: String, + changes: Vec, + diagnostics: Value, + deployment_id: Option, + staged_patch_id: Option, +} + +#[derive(Deserialize, serde::Serialize)] +struct ChangeOperationResult { + kind: String, + path: Option, + summary: Option, + status: String, + outputs: Option, +} + +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DesiredGraph { + pub(super) resources: Vec, +} + +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DesiredResource { + pub(super) address: Option, + pub(super) r#type: String, + pub(super) name: String, + pub(super) engine: Option, + pub(super) variables: Option>, + pub(super) source: Option, + pub(super) build: Option, + pub(super) deploy: Option, + pub(super) networking: Option, + pub(super) config: Option, +} + #[derive(Deserialize, serde::Serialize)] struct StagedPatch { id: String, @@ -93,20 +140,44 @@ struct StagedPatch { patch: Option, } +pub(super) async fn run(args: &Args, command: &str) -> Result { + let configs = Configs::new()?; + let linked_project = configs.get_linked_project().await?; + let (token, auth_type) = get_runner_token(&configs)?; + invoke_runner(args, &configs, &linked_project, &token, auth_type, command).await +} + pub async fn command(args: Args) -> Result<()> { let configs = Configs::new()?; let linked_project = configs.get_linked_project().await?; - let token = get_runner_token(&configs)?; - let command = if args.stage { "stage" } else { "plan" }; + let (token, auth_type) = get_runner_token(&configs)?; + let command = if args.stage { "stage" } else if args.yes { "apply" } else { "plan" }; if args.stage && !args.yes { - let preview = invoke_runner(&args, &configs, &linked_project, &token, "plan").await?; + let mut spinner = create_spinner_if(!args.json && std::io::stdout().is_terminal(), "Checking proposed changes".into()); + let preview = invoke_runner(&args, &configs, &linked_project, &token, auth_type, "plan").await?; + if let Some(spinner) = &mut spinner { + if preview.ok { + success_spinner(spinner, "Checked proposed changes".into()); + } else { + fail_spinner(spinner, "Could not check proposed changes".into()); + } + } + if has_destructive_changes(&preview) { - bail!("Plan contains destructive changes. Re-run with --yes to stage."); + bail!("These changes remove Railway resources. Re-run with --stage --yes to stage them."); } } - let output = invoke_runner(&args, &configs, &linked_project, &token, command).await?; + let mut spinner = create_spinner_if(!args.json && std::io::stdout().is_terminal(), runner_message(command).into()); + let output = invoke_runner(&args, &configs, &linked_project, &token, auth_type, command).await?; + if let Some(spinner) = &mut spinner { + if output.ok { + success_spinner(spinner, runner_done_message(command).into()); + } else { + fail_spinner(spinner, "Could not read Railway configuration".into()); + } + } if args.json { println!("{}", serde_json::to_string_pretty(&output)?); @@ -116,7 +187,7 @@ pub async fn command(args: Args) -> Result<()> { return Ok(()); } - print_response(&output); + print_response_with_options(&output, args.verbose); if !output.ok { bail!("IaC runner returned diagnostics"); } @@ -124,14 +195,15 @@ pub async fn command(args: Args) -> Result<()> { Ok(()) } -fn get_runner_token(configs: &Configs) -> Result { - if Configs::get_railway_token().is_some() { - bail!("railway sync currently requires a user/API token; project tokens are not supported by the TypeScript IaC runner yet") +fn get_runner_token(configs: &Configs) -> Result<(String, &'static str)> { + if let Some(token) = Configs::get_railway_token() { + return Ok((token, "project-token")); } configs .get_railway_auth_token() - .context("Not authenticated. Run `railway login` or set RAILWAY_API_TOKEN.") + .map(|token| (token, "bearer")) + .context("Not authenticated. Run `railway login`, set RAILWAY_API_TOKEN, or set RAILWAY_TOKEN.") } async fn invoke_runner( @@ -139,6 +211,7 @@ async fn invoke_runner( configs: &Configs, linked_project: &LinkedProject, token: &str, + auth_type: &str, command: &str, ) -> Result { let runner = args @@ -147,16 +220,23 @@ async fn invoke_runner( .or_else(|| env::var("RAILWAY_IAC_TS_BIN").ok()) .unwrap_or_else(|| "railway-iac-ts".to_string()); + let cwd = env::current_dir() + .context("Unable to get current working directory")? + .to_string_lossy() + .to_string(); + let request = serde_json::json!({ "command": command, + "cwd": cwd, "file": args.file.as_ref().map(|path| path.to_string_lossy().to_string()), "includeTypes": args.include_types, "pretty": false, "backboard": { "endpoint": configs.get_backboard(), "token": token, - "projectId": args.project_id.as_deref().unwrap_or(&linked_project.project), - "environmentId": args.environment_id.as_deref().unwrap_or(&linked_project.environment), + "authType": auth_type, + "projectId": linked_project.project, + "environmentId": linked_project.environment, "decryptVariables": args.decrypt_variables, "merge": true } @@ -221,23 +301,40 @@ fn has_destructive_changes(response: &RunnerResponse) -> bool { .unwrap_or(false) } -fn print_response(response: &RunnerResponse) { - println!("{}", "Railway IaC sync".bold()); - println!("runner: {}", response.command); - println!("file: {}", response.file); +fn runner_message(command: &str) -> &'static str { + match command { + "apply" => "Applying Railway configuration", + "stage" => "Checking Railway configuration", + _ => "Checking Railway configuration", + } +} + +fn runner_done_message(command: &str) -> &'static str { + match command { + "apply" => "Applied Railway configuration", + "stage" => "Checked Railway configuration", + _ => "Checked Railway configuration", + } +} + +pub(super) fn print_response(response: &RunnerResponse) { + print_response_with_options(response, false); +} + +pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bool) { + println!(); + println!("{}", "Railway configuration".bold()); + println!("{} {}", "File".dimmed(), response.file.cyan()); if let Some(environment) = &response.current_environment { - println!( - "project: {}", - environment.project_id.as_deref().unwrap_or("(unknown)") - ); - println!( - "environment: {}", - environment - .environment_name - .as_deref() - .unwrap_or(&environment.environment_id) - ); + let environment_name = environment + .environment_name + .as_deref() + .unwrap_or(&environment.environment_id); + println!("{} {}", "Environment".dimmed(), environment_name.cyan()); + if let Some(project_id) = &environment.project_id { + println!("{} {}", "Project".dimmed(), project_id.dimmed()); + } } println!(); @@ -251,9 +348,9 @@ fn print_response(response: &RunnerResponse) { ) }; if diagnostic.severity == "error" { - println!("{}", text.red()); + println!("{} {}", "Error".red().bold(), text.red()); } else { - println!("{}", text.yellow()); + println!("{} {}", "Warning".yellow().bold(), text.yellow()); } } @@ -268,21 +365,21 @@ fn print_response(response: &RunnerResponse) { .unwrap_or(&[]); if changes.is_empty() { - println!("{}", "No changes.".green()); + println!("{}", "✓ Your Railway configuration is already up to date.".green()); } else { - println!("{}", "ChangeSet".bold()); - if let Some(diff) = &response.diff { - println!("{diff}"); + let total = changes.len(); + println!("{} {}", "Planned changes".bold(), format!("({total})").dimmed()); + if !verbose { + if let Some(diff) = &response.diff { + print_colored_diff(diff); + } else { + for change in changes { + print_change(change, verbose); + } + } } else { for change in changes { - println!( - "{}", - change - .summary - .as_deref() - .or(change.kind.as_deref()) - .unwrap_or("change") - ); + print_change(change, verbose); } } @@ -291,18 +388,129 @@ fn print_response(response: &RunnerResponse) { .filter(|change| change.severity.as_deref() == Some("destructive")) .count(); if destructive > 0 { - println!("{}", format!("{destructive} destructive change(s).").red()); + println!(); + println!( + "{} {}", + "!".red().bold(), + format!("{destructive} destructive change(s) will remove Railway resources or variables.").red() + ); } } - if let Some(staged_patch) = &response.staged_patch { - println!(); - println!( - "{}", - format!("Staged Backboard patch: {}", staged_patch.id).green() - ); - } else { - println!(); - println!("Run with {} to stage the proposed ChangeSet.", "--stage".cyan()); + println!(); + if let Some(apply_result) = &response.apply_result { + println!("{}", "✓ Railway configuration applied.".green().bold()); + println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); + if let Some(deployment_id) = response.deployment_id.as_ref().or(apply_result.deployment_id.as_ref()) { + println!("{} {}", "Deployment".dimmed(), deployment_id.dimmed()); + } + if let Some(staged_patch_id) = response.staged_patch_id.as_ref().or(apply_result.staged_patch_id.as_ref()) { + println!("{} {}", "Patch".dimmed(), staged_patch_id.dimmed()); + } + print_operation_results(apply_result); + } else if let Some(staged_patch) = &response.staged_patch { + println!("{}", "✓ Changes staged for review in Railway.".green().bold()); + println!("{} {}", "Stage".dimmed(), staged_patch.id.dimmed()); + } else if !changes.is_empty() { + if !verbose && changes.iter().any(|change| change.details.as_ref().is_some_and(|details| !details.is_empty())) { + println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); + } + println!("{}", "Next steps".bold()); + println!(" {} Preview only; nothing has changed yet.", "•".cyan()); + println!(" {} Run {} to apply them now.", "•".cyan(), "railway config apply".cyan()); + } +} + +fn print_operation_results(apply_result: &ChangeSetApplyResult) { + if apply_result.changes.is_empty() { + return; + } + println!(); + println!("{}", "Applied changes".bold()); + for change in &apply_result.changes { + let summary = change + .summary + .as_deref() + .or(change.path.as_deref()) + .unwrap_or(&change.kind); + let marker = match change.status.as_str() { + "applied" => "+".green().bold(), + "noop" => "=".dimmed(), + "failed" => "!".red().bold(), + _ => "•".cyan(), + }; + println!(" {} {} {}", marker, summary, format!("({})", change.status).dimmed()); + if let Some(outputs) = &change.outputs { + print_operation_outputs(outputs, 4); + } + } +} + +fn print_operation_outputs(value: &Value, indent: usize) { + match value { + Value::Object(object) => { + for (key, value) in object { + match value { + Value::Object(_) | Value::Array(_) => { + println!("{}{}", " ".repeat(indent), key.dimmed()); + print_operation_outputs(value, indent + 2); + } + _ => println!("{}{} {}", " ".repeat(indent), key.dimmed(), format_output_value(value).cyan()), + } + } + } + Value::Array(values) => { + for value in values { + print_operation_outputs(value, indent); + } + } + _ => println!("{}{}", " ".repeat(indent), format_output_value(value).cyan()), + } +} + +fn format_output_value(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Null => "null".to_string(), + _ => value.to_string(), + } +} + +fn print_change(change: &Change, verbose: bool) { + let summary = change + .summary + .as_deref() + .or(change.kind.as_deref()) + .unwrap_or("change"); + let marker = marker_for_change(change); + println!(" {} {}", marker, summary); + if verbose { + if let Some(details) = &change.details { + for detail in details { + println!(" {} {}", "└".dimmed(), detail.dimmed()); + } + } + } +} + +fn print_colored_diff(diff: &str) { + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("+ ") { + println!(" {} {}", "+".green().bold(), rest.green()); + } else if let Some(rest) = line.strip_prefix("- ") { + println!(" {} {}", "-".red().bold(), rest.red()); + } else if let Some(rest) = line.strip_prefix("~ ") { + println!(" {} {}", "~".yellow().bold(), rest.yellow()); + } else { + println!(" {line}"); + } + } +} + +fn marker_for_change(change: &Change) -> colored::ColoredString { + match change.kind.as_deref() { + Some("resource.create") | Some("variable.set") | Some("domain.create") => "+".green().bold(), + Some("resource.delete") | Some("variable.delete") => "-".red().bold(), + _ => "~".yellow().bold(), } } diff --git a/src/commands/up.rs b/src/commands/up.rs index fe16b6a5a..c3ffe461c 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -26,7 +26,7 @@ use crate::{ errors::RailwayError, subscription::subscribe_graphql, subscriptions::deployment::DeploymentStatus, - util::logs::print_log, + util::{logs::print_log, prompt::prompt_confirm_with_default}, }; use super::*; @@ -71,6 +71,18 @@ pub struct Args { #[clap(long)] /// Output logs in JSON format (implies CI mode behavior) json: bool, + + #[clap(long)] + /// Apply Railway configuration before deploying if .railway/railway.ts exists + sync: bool, + + #[clap(long)] + /// Do not apply Railway configuration before deploying + no_sync: bool, + + #[clap(long)] + /// Confirm Railway configuration prompts + yes: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -96,6 +108,8 @@ pub async fn command(args: Args) -> Result<()> { bail!("--environment is required when using --project"); } + let iac_service = maybe_sync_iac_before_up(&args).await?; + let linked_project = if args.project.is_none() { Some(configs.get_linked_project().await?) } else { @@ -126,7 +140,7 @@ pub async fn command(args: Args) -> Result<()> { })?; let environment_id = get_matched_environment(&project, environment)?.id; - let service = get_or_prompt_service(linked_project, project, args.service).await?; + let service = get_or_prompt_service(linked_project, project, args.service.clone().or(iac_service)).await?; let spinner = if std::io::stdout().is_terminal() && !args.json { let spinner = ProgressBar::new_spinner() @@ -244,7 +258,6 @@ pub async fn command(args: Args) -> Result<()> { println!("url: {url}"); } - let builder = client.post(url); let spinner = if std::io::stdout().is_terminal() && !args.json { let spinner = ProgressBar::new_spinner() .with_style( @@ -264,11 +277,20 @@ pub async fn command(args: Args) -> Result<()> { let body = arc.lock().unwrap().clone(); - let res = builder - .header("Content-Type", "multipart/form-data") - .body(body) - .send() - .await?; + let mut upload_attempt = 0; + let res = loop { + let res = client + .post(url.clone()) + .header("Content-Type", "multipart/form-data") + .body(body.clone()) + .send() + .await?; + if res.status() != 404 || upload_attempt >= 10 { + break res; + } + upload_attempt += 1; + tokio::time::sleep(Duration::from_secs(2)).await; + }; let status = res.status(); if status != 200 { @@ -441,6 +463,98 @@ pub async fn command(args: Args) -> Result<()> { Ok(()) } +async fn maybe_sync_iac_before_up(args: &Args) -> Result> { + if args.no_sync { + return Ok(None); + } + + let railway_file = match find_railway_file(std::env::current_dir()?) { + Some(file) => file, + None => return Ok(None), + }; + + let apply_sync = if args.yes { + true + } else if args.sync { + if !std::io::stdout().is_terminal() { + bail!("Applying Railway configuration before deploy requires --yes in non-interactive mode."); + } + prompt_confirm_with_default( + &format!( + "Found Railway configuration at {}. Apply project changes before deploying?", + railway_file.display() + ), + true, + )? + } else { + if !std::io::stdout().is_terminal() { + println!( + "Found Railway configuration at {}, skipping project changes in non-interactive mode. Use --sync --yes to apply before deploy.", + railway_file.display() + ); + return Ok(None); + } + + prompt_confirm_with_default( + &format!( + "Found Railway configuration at {}. Apply project changes before deploying?", + railway_file.display() + ), + true, + )? + }; + + if !apply_sync { + return Ok(None); + } + + let sync_args = crate::commands::sync::Args { + file: Some(railway_file), + stage: false, + json: args.json, + yes: true, + decrypt_variables: false, + include_types: false, + runner: None, + verbose: false, + }; + + let response = crate::commands::sync::run(&sync_args, "apply").await?; + crate::commands::sync::print_response(&response); + if !response.ok { + bail!("IaC runner returned diagnostics"); + } + Ok(infer_iac_deploy_service(&response)) +} + +fn infer_iac_deploy_service(response: &crate::commands::sync::RunnerResponse) -> Option { + let services = response + .desired_graph + .as_ref()? + .resources + .iter() + .filter(|resource| resource.r#type == "service") + .collect::>(); + if services.len() == 1 { + Some(services[0].name.clone()) + } else { + None + } +} + +fn find_railway_file(start: PathBuf) -> Option { + let mut cursor = start; + loop { + let file = cursor.join(".railway/railway.ts"); + if file.exists() { + return Some(file); + } + if !cursor.pop() { + return None; + } + } +} + struct DeployPaths { project_path: PathBuf, archive_prefix_path: PathBuf, diff --git a/src/macros.rs b/src/macros.rs index d2f97d444..64e920769 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -37,8 +37,9 @@ macro_rules! commands { s = s.name(stringify!($module)); s }; + let command_name = stringify!($module).strip_suffix("_command").unwrap_or(stringify!($module)); // Add this subcommand into the global CLI. - cmd = cmd.subcommand(sub); + cmd = cmd.subcommand(sub.name(command_name)); } )* cmd @@ -48,7 +49,7 @@ macro_rules! commands { pub async fn exec_cli(matches: clap::ArgMatches) -> anyhow::Result<()> { match matches.subcommand() { $( - Some((stringify!([<$module:snake>]), sub_matches)) => { + Some((name, sub_matches)) if name == stringify!([<$module:snake>]).strip_suffix("_command").unwrap_or(stringify!([<$module:snake>])) => { let args = <$module::Args as ::clap::FromArgMatches>::from_arg_matches(sub_matches) .map_err(anyhow::Error::from)?; $module::command(args).await?; diff --git a/src/main.rs b/src/main.rs index 2e89abe02..d1b5dc5e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod macros; commands!( add, completion, + config_command, connect, delete, deploy, From b98a3679d7099b45ac52412481797fc43bee4f81 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Mon, 1 Jun 2026 18:27:26 +0200 Subject: [PATCH 03/40] Use current graph for config pull --- src/commands/config_command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 3ecfc0f8a..276a7a88c 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -239,7 +239,7 @@ async fn load_current_graph(runner: Option) -> Result Date: Tue, 2 Jun 2026 11:04:23 +0200 Subject: [PATCH 04/40] Clarify Railway config commands --- src/commands/config_command.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 276a7a88c..a661c6074 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -6,7 +6,7 @@ use crate::util::prompt::prompt_select; use super::*; -/// Manage Railway configuration from .railway/railway.ts +/// Manage Railway project configuration from .railway/railway.ts #[derive(Parser)] pub struct Args { #[clap(subcommand)] @@ -15,19 +15,20 @@ pub struct Args { #[derive(Parser)] enum Command { - /// Preview project configuration changes + /// Preview the changes Railway would make from .railway/railway.ts without applying them Plan(SharedArgs), - /// Stage project configuration changes for review + /// Staged Railway configuration changes are not available yet; use `railway config plan` or `railway config apply` + #[clap(hide = true)] Stage(SharedArgs), - /// Apply project configuration changes + /// Apply the changes from .railway/railway.ts to the linked Railway project Apply(SharedArgs), - /// Create a Railway configuration file for this project + /// Create .railway/railway.ts for this repo or import from the linked project Init(InitArgs), - /// Pull current Railway project configuration into code + /// Import the linked Railway project's current configuration into .railway/railway.ts Pull(PullArgs), } @@ -108,7 +109,7 @@ struct PullArgs { pub async fn command(args: Args) -> Result<()> { match args.command { Command::Plan(args) => run_sync(args, false, false).await, - Command::Stage(args) => run_sync(args, true, false).await, + Command::Stage(_args) => bail!("Staged Railway configuration changes are not available yet. Run `railway config plan` to preview changes or `railway config apply` to apply them."), Command::Apply(args) => run_sync(args, false, true).await, Command::Init(args) => init_config(args).await, Command::Pull(args) => pull_config(args).await, From adaa3e1aa6f2606368f1c5f3a5e5e98fa4e76ba9 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:08:18 +0200 Subject: [PATCH 05/40] Improve config command description --- src/commands/config_command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index a661c6074..2e1f186c5 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -6,7 +6,7 @@ use crate::util::prompt::prompt_select; use super::*; -/// Manage Railway project configuration from .railway/railway.ts +/// Define, import, preview, and apply your Railway project from .railway/railway.ts #[derive(Parser)] pub struct Args { #[clap(subcommand)] From 6b908d9160c5510497d98770e4fb79af1cec971c Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:17:23 +0200 Subject: [PATCH 06/40] Prompt for config login and project link --- src/commands/config_command.rs | 2 +- src/commands/link.rs | 25 +++++++++++++++++++++++++ src/commands/login.rs | 4 ++++ src/commands/sync.rs | 32 ++++++++++++++++++++++++++------ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 2e1f186c5..54411da2b 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -127,7 +127,7 @@ async fn init_config(args: InitArgs) -> Result<()> { let railway_dir = cwd.join(".railway"); let railway_file = railway_dir.join("railway.ts"); let readme_file = railway_dir.join("README.md"); - let skill_dir = cwd.join(".skills").join("railway-config"); + let skill_dir = cwd.join(".agents").join("skills").join("railway-config"); let skill_file = skill_dir.join("SKILL.md"); create_parent(&railway_file)?; diff --git a/src/commands/link.rs b/src/commands/link.rs index ea80142dc..b94eb9465 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -53,6 +53,31 @@ struct LinkOutput { service_name: Option, } +pub async fn link_project_without_service() -> Result { + let mut configs = Configs::new()?; + let workspaces = workspaces().await?; + let workspace = select_workspace(None, None, workspaces)?; + let project = select_project(workspace, None)?; + let environment = select_environment(None, &project)?; + + configs.link_project( + project.id.clone(), + Some(project.name.clone()), + environment.id.clone(), + Some(environment.name.clone()), + )?; + configs.write()?; + + println!( + "\n{} {} {}", + "Project".green(), + project.name.magenta().bold(), + "linked successfully! 🎉".green() + ); + + configs.get_linked_project().await +} + pub async fn command(args: Args) -> Result<()> { let mut configs = Configs::new()?; diff --git a/src/commands/login.rs b/src/commands/login.rs index e0fd6d323..271b07642 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -27,6 +27,10 @@ pub struct Args { browserless: bool, } +pub async fn prompt_login() -> Result<()> { + command(Args { browserless: false }).await +} + pub async fn command(args: Args) -> Result<()> { interact_or!("Cannot login in non-interactive mode"); diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 4a8b32a07..a7b33adf8 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -141,16 +141,12 @@ struct StagedPatch { } pub(super) async fn run(args: &Args, command: &str) -> Result { - let configs = Configs::new()?; - let linked_project = configs.get_linked_project().await?; - let (token, auth_type) = get_runner_token(&configs)?; + let (configs, linked_project, token, auth_type) = ensure_config_context().await?; invoke_runner(args, &configs, &linked_project, &token, auth_type, command).await } pub async fn command(args: Args) -> Result<()> { - let configs = Configs::new()?; - let linked_project = configs.get_linked_project().await?; - let (token, auth_type) = get_runner_token(&configs)?; + let (configs, linked_project, token, auth_type) = ensure_config_context().await?; let command = if args.stage { "stage" } else if args.yes { "apply" } else { "plan" }; if args.stage && !args.yes { @@ -195,6 +191,30 @@ pub async fn command(args: Args) -> Result<()> { Ok(()) } +async fn ensure_config_context() -> Result<(Configs, LinkedProject, String, &'static str)> { + let configs = Configs::new()?; + let (token, auth_type) = match get_runner_token(&configs) { + Ok(token) => token, + Err(error) if std::io::stdout().is_terminal() => { + println!("{}", "Log in to Railway to continue.".bold()); + crate::commands::login::prompt_login().await?; + get_runner_token(&Configs::new()?).map_err(|_| error)? + } + Err(error) => return Err(error), + }; + + let linked_project = match configs.get_linked_project().await { + Ok(linked_project) => linked_project, + Err(_error) if std::io::stdout().is_terminal() => { + println!("{}", "Link a Railway project to continue.".bold()); + crate::commands::link::link_project_without_service().await? + } + Err(error) => return Err(error), + }; + + Ok((Configs::new()?, linked_project, token, auth_type)) +} + fn get_runner_token(configs: &Configs) -> Result<(String, &'static str)> { if let Some(token) = Configs::get_railway_token() { return Ok((token, "project-token")); From 6d442224e0d945731070f3da732442529d0dd347 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:21:40 +0200 Subject: [PATCH 07/40] Allow config flow to create project --- src/commands/link.rs | 117 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/src/commands/link.rs b/src/commands/link.rs index b94eb9465..da8b997bb 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -6,7 +6,7 @@ use std::fmt::Display; use crate::{ errors::RailwayError, - util::prompt::{fake_select, prompt_options, prompt_options_skippable}, + util::prompt::{fake_select, prompt_options, prompt_options_skippable, prompt_select, prompt_text_with_placeholder_if_blank}, workspace::{Project, Workspace, workspaces}, }; @@ -53,27 +53,91 @@ struct LinkOutput { service_name: Option, } +#[derive(Clone, Copy)] +enum LinkProjectChoice { + Existing, + New, +} + +impl Display for LinkProjectChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LinkProjectChoice::Existing => write!(f, "Use an existing project"), + LinkProjectChoice::New => write!(f, "Create a new project"), + } + } +} + pub async fn link_project_without_service() -> Result { let mut configs = Configs::new()?; let workspaces = workspaces().await?; - let workspace = select_workspace(None, None, workspaces)?; - let project = select_project(workspace, None)?; - let environment = select_environment(None, &project)?; - configs.link_project( - project.id.clone(), - Some(project.name.clone()), - environment.id.clone(), - Some(environment.name.clone()), - )?; - configs.write()?; + let choice = if std::io::stdout().is_terminal() { + prompt_select( + "How should Railway continue?", + vec![LinkProjectChoice::Existing, LinkProjectChoice::New], + )? + } else { + LinkProjectChoice::Existing + }; - println!( - "\n{} {} {}", - "Project".green(), - project.name.magenta().bold(), - "linked successfully! 🎉".green() - ); + match choice { + LinkProjectChoice::Existing => { + let workspace = select_workspace(None, None, workspaces)?; + let project = select_project(workspace, None)?; + let environment = select_environment(None, &project)?; + + configs.link_project( + project.id.clone(), + Some(project.name.clone()), + environment.id.clone(), + Some(environment.name.clone()), + )?; + configs.write()?; + + println!( + "\n{} {} {}", + "Project".green(), + project.name.magenta().bold(), + "linked successfully! 🎉".green() + ); + } + LinkProjectChoice::New => { + let client = GQLClient::new_authorized(&configs)?; + let workspace = select_workspace(None, None, workspaces)?; + let project_name = prompt_new_project_name()?; + let vars = mutations::project_create::Variables { + name: if project_name.is_empty() { None } else { Some(project_name) }, + description: None, + workspace_id: Some(workspace.id().to_owned()), + }; + let project = post_graphql::(&client, configs.get_backboard(), vars) + .await? + .project_create; + let environment = project + .environments + .edges + .first() + .context("No environments")? + .node + .clone(); + + configs.link_project( + project.id.clone(), + Some(project.name.clone()), + environment.id.clone(), + Some(environment.name.clone()), + )?; + configs.write()?; + + println!( + "\n{} {} {}", + "Created and linked project".green().bold(), + project.name.magenta().bold(), + format!("on {workspace}").green() + ); + } + } configs.get_linked_project().await } @@ -144,6 +208,25 @@ pub async fn command(args: Args) -> Result<()> { Ok(()) } +fn prompt_new_project_name() -> Result { + let default_name = std::env::current_dir() + .ok() + .and_then(|path| path.file_name().map(|name| name.to_string_lossy().to_string())) + .unwrap_or_else(|| "railway-project".to_string()); + + if !std::io::stdout().is_terminal() { + return Ok(default_name); + } + + let maybe_name = prompt_text_with_placeholder_if_blank( + "Project name", + &default_name, + &default_name, + )?; + let name = maybe_name.trim(); + Ok(if name.is_empty() { default_name } else { name.to_string() }) +} + fn select_service( project: &NormalisedProject, environment: &NormalisedEnvironment, From 47671018d96c74adf823dd9e042655f03581b39c Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:27:26 +0200 Subject: [PATCH 08/40] Polish Railway config output --- src/commands/sync.rs | 56 +++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index a7b33adf8..f4e4800a7 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -344,16 +344,18 @@ pub(super) fn print_response(response: &RunnerResponse) { pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bool) { println!(); println!("{}", "Railway configuration".bold()); - println!("{} {}", "File".dimmed(), response.file.cyan()); + println!("{} {}", "Using".dimmed(), response.file.cyan()); if let Some(environment) = &response.current_environment { let environment_name = environment .environment_name .as_deref() .unwrap_or(&environment.environment_id); - println!("{} {}", "Environment".dimmed(), environment_name.cyan()); - if let Some(project_id) = &environment.project_id { - println!("{} {}", "Project".dimmed(), project_id.dimmed()); + println!("{} {}", "Target".dimmed(), environment_name.cyan()); + if verbose { + if let Some(project_id) = &environment.project_id { + println!("{} {}", "Project".dimmed(), project_id.dimmed()); + } } } println!(); @@ -388,7 +390,8 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo println!("{}", "✓ Your Railway configuration is already up to date.".green()); } else { let total = changes.len(); - println!("{} {}", "Planned changes".bold(), format!("({total})").dimmed()); + let section = if response.command == "apply" { "Applying" } else { "Railway will" }; + println!("{} {}", section.bold(), format!("({total})").dimmed()); if !verbose { if let Some(diff) = &response.diff { print_colored_diff(diff); @@ -419,15 +422,20 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo println!(); if let Some(apply_result) = &response.apply_result { - println!("{}", "✓ Railway configuration applied.".green().bold()); - println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); - if let Some(deployment_id) = response.deployment_id.as_ref().or(apply_result.deployment_id.as_ref()) { - println!("{} {}", "Deployment".dimmed(), deployment_id.dimmed()); - } - if let Some(staged_patch_id) = response.staged_patch_id.as_ref().or(apply_result.staged_patch_id.as_ref()) { - println!("{} {}", "Patch".dimmed(), staged_patch_id.dimmed()); + println!("{}", "✓ Your Railway project is configured.".green().bold()); + if verbose { + println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); + if let Some(deployment_id) = response.deployment_id.as_ref().or(apply_result.deployment_id.as_ref()) { + println!("{} {}", "Deployment".dimmed(), deployment_id.dimmed()); + } + if let Some(staged_patch_id) = response.staged_patch_id.as_ref().or(apply_result.staged_patch_id.as_ref()) { + println!("{} {}", "Patch".dimmed(), staged_patch_id.dimmed()); + } } - print_operation_results(apply_result); + print_operation_results(apply_result, verbose); + println!(); + println!("{}", "Next".bold()); + println!(" {} Run {} to deploy your code.", "•".cyan(), "railway up".cyan()); } else if let Some(staged_patch) = &response.staged_patch { println!("{}", "✓ Changes staged for review in Railway.".green().bold()); println!("{} {}", "Stage".dimmed(), staged_patch.id.dimmed()); @@ -435,18 +443,18 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo if !verbose && changes.iter().any(|change| change.details.as_ref().is_some_and(|details| !details.is_empty())) { println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); } - println!("{}", "Next steps".bold()); - println!(" {} Preview only; nothing has changed yet.", "•".cyan()); - println!(" {} Run {} to apply them now.", "•".cyan(), "railway config apply".cyan()); + println!("{}", "Next".bold()); + println!(" {} Nothing has changed yet.", "•".cyan()); + println!(" {} Run {} to make it real.", "•".cyan(), "railway config apply".cyan()); } } -fn print_operation_results(apply_result: &ChangeSetApplyResult) { +fn print_operation_results(apply_result: &ChangeSetApplyResult, verbose: bool) { if apply_result.changes.is_empty() { return; } println!(); - println!("{}", "Applied changes".bold()); + println!("{}", "Applied".bold()); for change in &apply_result.changes { let summary = change .summary @@ -459,9 +467,15 @@ fn print_operation_results(apply_result: &ChangeSetApplyResult) { "failed" => "!".red().bold(), _ => "•".cyan(), }; - println!(" {} {} {}", marker, summary, format!("({})", change.status).dimmed()); - if let Some(outputs) = &change.outputs { - print_operation_outputs(outputs, 4); + if verbose { + println!(" {} {} {}", marker, summary, format!("({})", change.status).dimmed()); + } else { + println!(" {} {}", marker, summary); + } + if verbose { + if let Some(outputs) = &change.outputs { + print_operation_outputs(outputs, 4); + } } } } From 03c246759d5deb2f4a4e4ed6dac5a3b5da2b07c0 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:31:29 +0200 Subject: [PATCH 09/40] Refine config output spacing --- src/commands/sync.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index f4e4800a7..43237504f 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -351,7 +351,7 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo .environment_name .as_deref() .unwrap_or(&environment.environment_id); - println!("{} {}", "Target".dimmed(), environment_name.cyan()); + println!("{} {}", "Environment".dimmed(), environment_name.cyan()); if verbose { if let Some(project_id) = &environment.project_id { println!("{} {}", "Project".dimmed(), project_id.dimmed()); @@ -391,6 +391,7 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo } else { let total = changes.len(); let section = if response.command == "apply" { "Applying" } else { "Railway will" }; + println!(); println!("{} {}", section.bold(), format!("({total})").dimmed()); if !verbose { if let Some(diff) = &response.diff { @@ -422,6 +423,7 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo println!(); if let Some(apply_result) = &response.apply_result { + println!(); println!("{}", "✓ Your Railway project is configured.".green().bold()); if verbose { println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); @@ -434,6 +436,7 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo } print_operation_results(apply_result, verbose); println!(); + println!(); println!("{}", "Next".bold()); println!(" {} Run {} to deploy your code.", "•".cyan(), "railway up".cyan()); } else if let Some(staged_patch) = &response.staged_patch { From 9e0864d08a1336a9f3ac1cadd76173782df60072 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:34:03 +0200 Subject: [PATCH 10/40] Prompt to initialize Railway config --- src/commands/config_command.rs | 38 +++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 54411da2b..8ab665c6d 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -2,7 +2,7 @@ use std::{fs, path::{Path, PathBuf}}; use is_terminal::IsTerminal; -use crate::util::prompt::prompt_select; +use crate::util::prompt::{prompt_confirm_with_default, prompt_select}; use super::*; @@ -596,6 +596,8 @@ export default defineRailway(() => {{ } async fn run_sync(args: SharedArgs, stage: bool, apply: bool) -> Result<()> { + ensure_config_initialized(&args).await?; + crate::commands::sync::command(crate::commands::sync::Args { file: args.file, stage, @@ -608,3 +610,37 @@ async fn run_sync(args: SharedArgs, stage: bool, apply: bool) -> Result<()> { }) .await } + +async fn ensure_config_initialized(args: &SharedArgs) -> Result<()> { + if args.file.is_some() { + return Ok(()); + } + + let cwd = std::env::current_dir().context("Unable to get current directory")?; + let railway_file = cwd.join(".railway").join("railway.ts"); + if railway_file.exists() { + return Ok(()); + } + + println!(); + println!("{}", "Railway configuration is not initialized yet.".bold()); + println!("{} {}", "Create".dimmed(), railway_file.display().to_string().cyan()); + println!(); + + let should_init = if args.yes { + true + } else { + if !std::io::stdout().is_terminal() { + bail!("Railway configuration is not initialized. Run `railway config init` first."); + } + prompt_confirm_with_default("Initialize Railway configuration for this project?", false)? + }; + + if !should_init { + bail!("Run `railway config init` to create .railway/railway.ts, then try again."); + } + + init_config(InitArgs { force: false }).await?; + println!(); + Ok(()) +} From c580c4097222a99d558bead21b711d5aa0b175c2 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:36:53 +0200 Subject: [PATCH 11/40] Polish config init prompt --- src/commands/config_command.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 8ab665c6d..27e9f9d64 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -73,9 +73,9 @@ enum InitMode { impl std::fmt::Display for InitMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - InitMode::GenerateFromRepo => write!(f, "Generate from this repo"), - InitMode::ImportFromRailway => write!(f, "Import from linked Railway project"), - InitMode::MinimalFile => write!(f, "Create minimal file"), + InitMode::GenerateFromRepo => write!(f, "Start from this directory"), + InitMode::ImportFromRailway => write!(f, "Import an existing Railway project"), + InitMode::MinimalFile => write!(f, "Start with a blank Railway project"), } } } @@ -137,7 +137,7 @@ async fn init_config(args: InitArgs) -> Result<()> { InitMode::GenerateFromRepo } else { prompt_select( - "How should Railway start your configuration?", + "How should Railway create .railway/railway.ts?", vec![ InitMode::GenerateFromRepo, InitMode::ImportFromRailway, From d5c417aaa72b1c4b622c2750b3cb9767cd8e730a Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:40:44 +0200 Subject: [PATCH 12/40] Refine prompt styling --- src/commands/config_command.rs | 3 ++- src/config.rs | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 27e9f9d64..b7aad933d 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -136,8 +136,9 @@ async fn init_config(args: InitArgs) -> Result<()> { let init_mode = if railway_file.exists() || !std::io::stdout().is_terminal() { InitMode::GenerateFromRepo } else { + println!(); prompt_select( - "How should Railway create .railway/railway.ts?", + "How should Railway create .railway/railway.ts?\n", vec![ InitMode::GenerateFromRepo, InitMode::ImportFromRailway, diff --git a/src/config.rs b/src/config.rs index cae594777..2c462c369 100644 --- a/src/config.rs +++ b/src/config.rs @@ -344,7 +344,7 @@ impl Configs { } pub fn get_render_config() -> RenderConfig<'static> { - RenderConfig::default_colored() + let mut config = RenderConfig::default_colored() .with_help_message( StyleSheet::new() .with_fg(inquire::ui::Color::LightMagenta) @@ -362,9 +362,22 @@ impl Configs { .with_attr(Attributes::BOLD), ), ) + .with_highlighted_option_prefix( + Styled::new("›").with_fg(inquire::ui::Color::LightCyan), + ) + .with_option( + StyleSheet::new(), + ) + .with_selected_option(Some( + StyleSheet::new() + .with_fg(inquire::ui::Color::LightCyan) + .with_attr(Attributes::BOLD), + )) .with_canceled_prompt_indicator( Styled::new("").with_fg(inquire::ui::Color::DarkRed), - ) + ); + config.prompt = StyleSheet::new().with_attr(Attributes::BOLD); + config } pub fn write(&self) -> Result<()> { From 798dbc1c1139aa1850e43bf2410cdd0a106a1e4b Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 11:48:37 +0200 Subject: [PATCH 13/40] Clarify config initialization flow --- src/commands/config_command.rs | 10 +++++++--- src/config.rs | 17 ++--------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index b7aad933d..a0fa17e1d 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -73,9 +73,9 @@ enum InitMode { impl std::fmt::Display for InitMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - InitMode::GenerateFromRepo => write!(f, "Start from this directory"), + InitMode::GenerateFromRepo => write!(f, "Scan this directory and suggest a basic setup"), InitMode::ImportFromRailway => write!(f, "Import an existing Railway project"), - InitMode::MinimalFile => write!(f, "Start with a blank Railway project"), + InitMode::MinimalFile => write!(f, "Create an empty configuration file"), } } } @@ -136,9 +136,13 @@ async fn init_config(args: InitArgs) -> Result<()> { let init_mode = if railway_file.exists() || !std::io::stdout().is_terminal() { InitMode::GenerateFromRepo } else { + println!(); + println!("{}", "Initialize Railway configuration".bold()); + println!("Railway will create the files that define your project infrastructure as code."); + println!("{} {}", "Main file".dimmed(), ".railway/railway.ts".cyan()); println!(); prompt_select( - "How should Railway create .railway/railway.ts?\n", + "How should Railway start?", vec![ InitMode::GenerateFromRepo, InitMode::ImportFromRailway, diff --git a/src/config.rs b/src/config.rs index 2c462c369..cae594777 100644 --- a/src/config.rs +++ b/src/config.rs @@ -344,7 +344,7 @@ impl Configs { } pub fn get_render_config() -> RenderConfig<'static> { - let mut config = RenderConfig::default_colored() + RenderConfig::default_colored() .with_help_message( StyleSheet::new() .with_fg(inquire::ui::Color::LightMagenta) @@ -362,22 +362,9 @@ impl Configs { .with_attr(Attributes::BOLD), ), ) - .with_highlighted_option_prefix( - Styled::new("›").with_fg(inquire::ui::Color::LightCyan), - ) - .with_option( - StyleSheet::new(), - ) - .with_selected_option(Some( - StyleSheet::new() - .with_fg(inquire::ui::Color::LightCyan) - .with_attr(Attributes::BOLD), - )) .with_canceled_prompt_indicator( Styled::new("").with_fg(inquire::ui::Color::DarkRed), - ); - config.prompt = StyleSheet::new().with_attr(Attributes::BOLD); - config + ) } pub fn write(&self) -> Result<()> { From c7aa8ecdb43bcb276753e23a5109e380cae4bdd0 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:09:10 +0200 Subject: [PATCH 14/40] Tighten config link narrative --- src/commands/link.rs | 6 +++--- src/commands/sync.rs | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/commands/link.rs b/src/commands/link.rs index da8b997bb..e80732a32 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -62,8 +62,8 @@ enum LinkProjectChoice { impl Display for LinkProjectChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - LinkProjectChoice::Existing => write!(f, "Use an existing project"), - LinkProjectChoice::New => write!(f, "Create a new project"), + LinkProjectChoice::Existing => write!(f, "Use an existing Railway project"), + LinkProjectChoice::New => write!(f, "Create a new Railway project"), } } } @@ -74,7 +74,7 @@ pub async fn link_project_without_service() -> Result { let choice = if std::io::stdout().is_terminal() { prompt_select( - "How should Railway continue?", + "Where should Railway apply this configuration?", vec![LinkProjectChoice::Existing, LinkProjectChoice::New], )? } else { diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 43237504f..c8b0031ca 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -206,7 +206,9 @@ async fn ensure_config_context() -> Result<(Configs, LinkedProject, String, &'st let linked_project = match configs.get_linked_project().await { Ok(linked_project) => linked_project, Err(_error) if std::io::stdout().is_terminal() => { - println!("{}", "Link a Railway project to continue.".bold()); + println!(); + println!("{}", "Connect Railway configuration".bold()); + println!("Choose where .railway/railway.ts should plan and apply changes."); crate::commands::link::link_project_without_service().await? } Err(error) => return Err(error), @@ -344,7 +346,7 @@ pub(super) fn print_response(response: &RunnerResponse) { pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bool) { println!(); println!("{}", "Railway configuration".bold()); - println!("{} {}", "Using".dimmed(), response.file.cyan()); + println!("{} {}", "Using".dimmed(), display_file_path(&response.file).cyan()); if let Some(environment) = &response.current_environment { let environment_name = environment @@ -391,7 +393,6 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo } else { let total = changes.len(); let section = if response.command == "apply" { "Applying" } else { "Railway will" }; - println!(); println!("{} {}", section.bold(), format!("({total})").dimmed()); if !verbose { if let Some(diff) = &response.diff { @@ -447,11 +448,22 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); } println!("{}", "Next".bold()); - println!(" {} Nothing has changed yet.", "•".cyan()); - println!(" {} Run {} to make it real.", "•".cyan(), "railway config apply".cyan()); + println!(" {} No changes have been applied yet.", "•".cyan()); + println!(" {} Run {} to make this real.", "•".cyan(), "railway config apply".cyan()); } } +fn display_file_path(path: &str) -> String { + let path = PathBuf::from(path); + let cwd = std::env::current_dir().ok(); + let display_path = cwd + .as_ref() + .and_then(|cwd| path.strip_prefix(cwd).ok()) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(&path); + display_path.display().to_string() +} + fn print_operation_results(apply_result: &ChangeSetApplyResult, verbose: bool) { if apply_result.changes.is_empty() { return; From cfc5471f9fbbb448374a654d34fe0487fa4e76da Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:19:55 +0200 Subject: [PATCH 15/40] Compress config apply output --- src/commands/sync.rs | 51 ++++++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index c8b0031ca..e2a5faaf9 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -388,12 +388,26 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo .map(|change_set| change_set.changes.as_slice()) .unwrap_or(&[]); + if let Some(apply_result) = &response.apply_result { + print_operation_results(apply_result, verbose); + if verbose { + println!(); + println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); + if let Some(deployment_id) = response.deployment_id.as_ref().or(apply_result.deployment_id.as_ref()) { + println!("{} {}", "Deployment".dimmed(), deployment_id.dimmed()); + } + if let Some(staged_patch_id) = response.staged_patch_id.as_ref().or(apply_result.staged_patch_id.as_ref()) { + println!("{} {}", "Patch".dimmed(), staged_patch_id.dimmed()); + } + } + return; + } + if changes.is_empty() { println!("{}", "✓ Your Railway configuration is already up to date.".green()); } else { let total = changes.len(); - let section = if response.command == "apply" { "Applying" } else { "Railway will" }; - println!("{} {}", section.bold(), format!("({total})").dimmed()); + println!("{} {}", "Changes".bold(), format!("({total})").dimmed()); if !verbose { if let Some(diff) = &response.diff { print_colored_diff(diff); @@ -420,36 +434,13 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo format!("{destructive} destructive change(s) will remove Railway resources or variables.").red() ); } - } - println!(); - if let Some(apply_result) = &response.apply_result { - println!(); - println!("{}", "✓ Your Railway project is configured.".green().bold()); - if verbose { - println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); - if let Some(deployment_id) = response.deployment_id.as_ref().or(apply_result.deployment_id.as_ref()) { - println!("{} {}", "Deployment".dimmed(), deployment_id.dimmed()); - } - if let Some(staged_patch_id) = response.staged_patch_id.as_ref().or(apply_result.staged_patch_id.as_ref()) { - println!("{} {}", "Patch".dimmed(), staged_patch_id.dimmed()); - } - } - print_operation_results(apply_result, verbose); - println!(); println!(); - println!("{}", "Next".bold()); - println!(" {} Run {} to deploy your code.", "•".cyan(), "railway up".cyan()); - } else if let Some(staged_patch) = &response.staged_patch { - println!("{}", "✓ Changes staged for review in Railway.".green().bold()); - println!("{} {}", "Stage".dimmed(), staged_patch.id.dimmed()); - } else if !changes.is_empty() { if !verbose && changes.iter().any(|change| change.details.as_ref().is_some_and(|details| !details.is_empty())) { println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); } println!("{}", "Next".bold()); - println!(" {} No changes have been applied yet.", "•".cyan()); - println!(" {} Run {} to make this real.", "•".cyan(), "railway config apply".cyan()); + println!(" {} Run {} to apply these changes.", "•".cyan(), "railway config apply".cyan()); } } @@ -468,8 +459,8 @@ fn print_operation_results(apply_result: &ChangeSetApplyResult, verbose: bool) { if apply_result.changes.is_empty() { return; } - println!(); - println!("{}", "Applied".bold()); + let total = apply_result.changes.len(); + println!("{} {}", "Changes".bold(), format!("({total})").dimmed()); for change in &apply_result.changes { let summary = change .summary @@ -477,9 +468,9 @@ fn print_operation_results(apply_result: &ChangeSetApplyResult, verbose: bool) { .or(change.path.as_deref()) .unwrap_or(&change.kind); let marker = match change.status.as_str() { - "applied" => "+".green().bold(), + "applied" => "✓".green().bold(), "noop" => "=".dimmed(), - "failed" => "!".red().bold(), + "failed" => "✕".red().bold(), _ => "•".cyan(), }; if verbose { From b224a761ea0bc3317acf808465ac53f0c6f98bc0 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:29:07 +0200 Subject: [PATCH 16/40] Confirm config apply changes --- src/commands/config_command.rs | 4 ++- src/commands/sync.rs | 46 +++++++++++++++++++++++++++++++--- src/commands/up.rs | 1 + 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index a0fa17e1d..5d392e366 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -240,6 +240,7 @@ async fn load_current_graph(runner: Option) -> Result Result<()> { file: args.file, stage, json: args.json, - yes: apply || args.yes, + yes: args.yes, + apply, decrypt_variables: args.decrypt_variables, include_types: args.include_types, runner: args.runner, diff --git a/src/commands/sync.rs b/src/commands/sync.rs index e2a5faaf9..23889d262 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use serde_json::Value; use tokio::{io::AsyncWriteExt, process::Command}; -use crate::util::progress::{create_spinner_if, fail_spinner, success_spinner}; +use crate::util::{progress::{create_spinner_if, fail_spinner, success_spinner}, prompt::prompt_confirm_with_default}; use super::*; @@ -25,10 +25,13 @@ pub struct Args { #[clap(long)] pub(super) json: bool, - /// Confirm destructive staged changes. + /// Confirm prompts and proceed non-interactively. #[clap(long)] pub(super) yes: bool, + #[clap(skip)] + pub(super) apply: bool, + /// Ask Backboard to decrypt variables while planning, when authorized. #[clap(long)] pub(super) decrypt_variables: bool, @@ -147,7 +150,7 @@ pub(super) async fn run(args: &Args, command: &str) -> Result { pub async fn command(args: Args) -> Result<()> { let (configs, linked_project, token, auth_type) = ensure_config_context().await?; - let command = if args.stage { "stage" } else if args.yes { "apply" } else { "plan" }; + let command = if args.stage { "stage" } else if args.apply || args.yes { "apply" } else { "plan" }; if args.stage && !args.yes { let mut spinner = create_spinner_if(!args.json && std::io::stdout().is_terminal(), "Checking proposed changes".into()); @@ -165,6 +168,43 @@ pub async fn command(args: Args) -> Result<()> { } } + if command == "apply" && !args.yes && !args.json { + if !std::io::stdout().is_terminal() { + bail!("Run `railway config apply --yes` to apply changes non-interactively."); + } + + let mut spinner = create_spinner_if(true, "Checking Railway configuration".into()); + let preview = invoke_runner(&args, &configs, &linked_project, &token, auth_type, "plan").await?; + if let Some(spinner) = &mut spinner { + if preview.ok { + success_spinner(spinner, "Checked Railway configuration".into()); + } else { + fail_spinner(spinner, "Could not read Railway configuration".into()); + } + } + + print_response_with_options(&preview, args.verbose); + if !preview.ok { + bail!("IaC runner returned diagnostics"); + } + let changes = preview.change_set.as_ref().map(|change_set| change_set.changes.len()).unwrap_or(0); + if changes == 0 { + return Ok(()); + } + + let destructive = has_destructive_changes(&preview); + println!(); + let prompt = if destructive { + "Apply these changes? This will remove Railway resources or variables." + } else { + "Apply these changes to Railway?" + }; + if !prompt_confirm_with_default(prompt, false)? { + bail!("No changes applied."); + } + println!(); + } + let mut spinner = create_spinner_if(!args.json && std::io::stdout().is_terminal(), runner_message(command).into()); let output = invoke_runner(&args, &configs, &linked_project, &token, auth_type, command).await?; if let Some(spinner) = &mut spinner { diff --git a/src/commands/up.rs b/src/commands/up.rs index c3ffe461c..a4cc99562 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -513,6 +513,7 @@ async fn maybe_sync_iac_before_up(args: &Args) -> Result> { stage: false, json: args.json, yes: true, + apply: true, decrypt_variables: false, include_types: false, runner: None, From f45ff98e813b6af6a9070587b3d44b85a37d4028 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:37:57 +0200 Subject: [PATCH 17/40] Hide plan next step during apply confirmation --- src/commands/sync.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 23889d262..0317eaca2 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -183,7 +183,7 @@ pub async fn command(args: Args) -> Result<()> { } } - print_response_with_options(&preview, args.verbose); + print_response_with_options_and_next(&preview, args.verbose, false); if !preview.ok { bail!("IaC runner returned diagnostics"); } @@ -384,6 +384,10 @@ pub(super) fn print_response(response: &RunnerResponse) { } pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bool) { + print_response_with_options_and_next(response, verbose, true); +} + +fn print_response_with_options_and_next(response: &RunnerResponse, verbose: bool, show_next: bool) { println!(); println!("{}", "Railway configuration".bold()); println!("{} {}", "Using".dimmed(), display_file_path(&response.file).cyan()); @@ -475,12 +479,14 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo ); } - println!(); - if !verbose && changes.iter().any(|change| change.details.as_ref().is_some_and(|details| !details.is_empty())) { - println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); + if show_next { + println!(); + if !verbose && changes.iter().any(|change| change.details.as_ref().is_some_and(|details| !details.is_empty())) { + println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); + } + println!("{}", "Next".bold()); + println!(" {} Run {} to apply these changes.", "•".cyan(), "railway config apply".cyan()); } - println!("{}", "Next".bold()); - println!(" {} Run {} to apply these changes.", "•".cyan(), "railway config apply".cyan()); } } From 1b74cf0e98c342955ecafb475cff7d079cf89cd2 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:39:03 +0200 Subject: [PATCH 18/40] Align completed spinner output --- src/util/progress.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/util/progress.rs b/src/util/progress.rs index 681ef7c9c..649207f10 100644 --- a/src/util/progress.rs +++ b/src/util/progress.rs @@ -35,5 +35,10 @@ pub fn fail_spinner(spinner: &mut ProgressBar, message: String) { } pub fn success_spinner(spinner: &mut ProgressBar, message: String) { + spinner.set_style( + ProgressStyle::default_spinner() + .template("{msg:.green}") + .expect("Failed to create success spinner template"), + ); spinner.finish_with_message(format!("✓ {message}")); } From 6bfae833c7ef1c9dc2a83d2c72d5a1fa1335aff4 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:44:18 +0200 Subject: [PATCH 19/40] Improve config init project detection --- src/commands/config_command.rs | 75 +++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 5d392e366..3574c1150 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -1,4 +1,4 @@ -use std::{fs, path::{Path, PathBuf}}; +use std::{fs, path::{Path, PathBuf}, process::Command as ProcessCommand}; use is_terminal::IsTerminal; @@ -530,29 +530,33 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { .unwrap_or_default(); let scripts = package.get("scripts").and_then(|scripts| scripts.as_object()); let package_manager = detect_package_manager(cwd); - let install = match package_manager.as_str() { - "bun" => "bun install --frozen-lockfile", - "pnpm" => "pnpm install --frozen-lockfile", - "yarn" => "yarn install --frozen-lockfile", - _ => "npm ci", - }; - let build = scripts - .and_then(|scripts| scripts.get("build")) - .and_then(|value| value.as_str()) - .map(|_| format!("{install} && {package_manager} run build")); - let start = if scripts.and_then(|scripts| scripts.get("start")).is_some() { - Some(format!("{package_manager} run start")) - } else if cwd.join("src/index.ts").exists() && package_manager == "bun" { - Some("bun src/index.ts".to_string()) - } else if cwd.join("index.js").exists() { - Some("node index.js".to_string()) + let build = script_command(scripts, "build").map(|_| format!("{package_manager} run build")); + let start = script_command(scripts, "start") + .map(ToOwned::to_owned) + .or_else(|| { + if cwd.join("src/index.ts").exists() && package_manager == "bun" { + Some("bun src/index.ts".to_string()) + } else if cwd.join("index.js").exists() { + Some("node index.js".to_string()) + } else { + None + } + }); + let github_source = detect_github_remote(cwd); + + let imports = if github_source.is_some() { + "defineRailway, github, project, service" } else { - None + "defineRailway, project, service" }; - - let mut out = "import { defineRailway, project, service } from \"railway/iac\";\n\n".to_string(); + let mut out = format!("import {{ {imports} }} from \"railway/iac\";\n\n"); out.push_str("export default defineRailway(() => {\n"); out.push_str(" const web = service(\"web\", {\n"); + if let Some(source) = github_source { + out.push_str(&format!(" source: github({:?}),\n", source)); + } else { + out.push_str(" // No GitHub remote detected. `railway up` will upload this directory.\n"); + } if let Some(build) = build { out.push_str(&format!(" build: {:?},\n", build)); } @@ -566,6 +570,37 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { out } +fn script_command<'a>(scripts: Option<&'a serde_json::Map>, name: &str) -> Option<&'a str> { + scripts + .and_then(|scripts| scripts.get(name)) + .and_then(|value| value.as_str()) +} + +fn detect_github_remote(cwd: &Path) -> Option { + let output = ProcessCommand::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(cwd) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_github_remote(std::str::from_utf8(&output.stdout).ok()?.trim()) +} + +fn parse_github_remote(remote: &str) -> Option { + let remote = remote.strip_suffix(".git").unwrap_or(remote); + if let Some(path) = remote.strip_prefix("git@github.com:") { + return Some(path.to_string()); + } + for prefix in ["https://github.com/", "http://github.com/", "ssh://git@github.com/"] { + if let Some(path) = remote.strip_prefix(prefix) { + return Some(path.to_string()); + } + } + None +} + fn detect_package_manager(cwd: &Path) -> String { if cwd.join("bun.lock").exists() || cwd.join("bun.lockb").exists() { "bun".to_string() From 61c49accdabb9d83e67aeef40602715e4d1a8174 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 12:56:11 +0200 Subject: [PATCH 20/40] Preview config changes before up --- src/commands/sync.rs | 10 ++--- src/commands/up.rs | 89 ++++++++++++++++++++++++++------------------ 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 0317eaca2..55b441b5a 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -56,7 +56,7 @@ pub(super) struct RunnerResponse { command: String, file: String, current_environment: Option, - change_set: Option, + pub(super) change_set: Option, diff: Option, diagnostics: Vec, pub(super) current_graph: Option, @@ -76,12 +76,12 @@ struct CurrentEnvironment { } #[derive(Deserialize, serde::Serialize)] -struct ChangeSet { - changes: Vec, +pub(super) struct ChangeSet { + pub(super) changes: Vec, } #[derive(Deserialize, serde::Serialize)] -struct Change { +pub(super) struct Change { summary: Option, severity: Option, kind: Option, @@ -387,7 +387,7 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo print_response_with_options_and_next(response, verbose, true); } -fn print_response_with_options_and_next(response: &RunnerResponse, verbose: bool, show_next: bool) { +pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, verbose: bool, show_next: bool) { println!(); println!("{}", "Railway configuration".bold()); println!("{} {}", "Using".dimmed(), display_file_path(&response.file).cyan()); diff --git a/src/commands/up.rs b/src/commands/up.rs index a4cc99562..654a68f4f 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -1,5 +1,5 @@ use std::{ - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; @@ -473,54 +473,59 @@ async fn maybe_sync_iac_before_up(args: &Args) -> Result> { None => return Ok(None), }; - let apply_sync = if args.yes { - true - } else if args.sync { - if !std::io::stdout().is_terminal() { - bail!("Applying Railway configuration before deploy requires --yes in non-interactive mode."); - } - prompt_confirm_with_default( - &format!( - "Found Railway configuration at {}. Apply project changes before deploying?", - railway_file.display() - ), - true, - )? - } else { + let sync_args = crate::commands::sync::Args { + file: Some(railway_file.clone()), + stage: false, + json: args.json, + yes: true, + apply: false, + decrypt_variables: false, + include_types: false, + runner: None, + verbose: false, + }; + + let plan = crate::commands::sync::run(&sync_args, "plan").await?; + if !plan.ok { + crate::commands::sync::print_response(&plan); + bail!("IaC runner returned diagnostics"); + } + + let changes = plan.change_set.as_ref().map(|change_set| change_set.changes.len()).unwrap_or(0); + if changes == 0 { + crate::commands::sync::print_response(&plan); + return Ok(infer_iac_deploy_service(&plan)); + } + + if !args.yes { if !std::io::stdout().is_terminal() { + if args.sync { + bail!("Applying Railway configuration before deploy requires --yes in non-interactive mode."); + } println!( "Found Railway configuration at {}, skipping project changes in non-interactive mode. Use --sync --yes to apply before deploy.", - railway_file.display() + display_path(&railway_file) ); return Ok(None); } - prompt_confirm_with_default( - &format!( - "Found Railway configuration at {}. Apply project changes before deploying?", - railway_file.display() - ), + crate::commands::sync::print_response_with_options_and_next(&plan, false, false); + println!(); + let apply_sync = prompt_confirm_with_default( + "Apply these Railway configuration changes before deploying?", true, - )? - }; - - if !apply_sync { - return Ok(None); + )?; + if !apply_sync { + return Ok(infer_iac_deploy_service(&plan)); + } + println!(); } - let sync_args = crate::commands::sync::Args { - file: Some(railway_file), - stage: false, - json: args.json, - yes: true, + let apply_args = crate::commands::sync::Args { apply: true, - decrypt_variables: false, - include_types: false, - runner: None, - verbose: false, + ..sync_args }; - - let response = crate::commands::sync::run(&sync_args, "apply").await?; + let response = crate::commands::sync::run(&apply_args, "apply").await?; crate::commands::sync::print_response(&response); if !response.ok { bail!("IaC runner returned diagnostics"); @@ -528,6 +533,16 @@ async fn maybe_sync_iac_before_up(args: &Args) -> Result> { Ok(infer_iac_deploy_service(&response)) } +fn display_path(path: &Path) -> String { + let cwd = std::env::current_dir().ok(); + let display_path = cwd + .as_ref() + .and_then(|cwd| path.strip_prefix(cwd).ok()) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(path); + display_path.display().to_string() +} + fn infer_iac_deploy_service(response: &crate::commands::sync::RunnerResponse) -> Option { let services = response .desired_graph From acc11d53f9fa3b0f513767d775cffd24a6eea56b Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:05:22 +0200 Subject: [PATCH 21/40] Show verbose config preview before up --- src/commands/up.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/up.rs b/src/commands/up.rs index 654a68f4f..cebb9210b 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -509,7 +509,7 @@ async fn maybe_sync_iac_before_up(args: &Args) -> Result> { return Ok(None); } - crate::commands::sync::print_response_with_options_and_next(&plan, false, false); + crate::commands::sync::print_response_with_options_and_next(&plan, true, false); println!(); let apply_sync = prompt_confirm_with_default( "Apply these Railway configuration changes before deploying?", From 66e6eb60581032e4c70805d3ab404519445fe5cc Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:15:32 +0200 Subject: [PATCH 22/40] Create config support files on pull --- src/commands/config_command.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 3574c1150..e469133fc 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -200,6 +200,8 @@ fn write_new(path: &Path, contents: &str, force: bool) -> Result<()> { async fn pull_config(args: PullArgs) -> Result<()> { let cwd = std::env::current_dir().context("Unable to get current directory")?; let railway_file = cwd.join(".railway").join("railway.ts"); + let readme_file = cwd.join(".railway").join("README.md"); + let skill_file = cwd.join(".agents").join("skills").join("railway-config").join("SKILL.md"); if args.json { let graph = load_current_graph(args.runner).await?; @@ -208,10 +210,19 @@ async fn pull_config(args: PullArgs) -> Result<()> { } create_parent(&railway_file)?; + create_parent(&skill_file)?; write_pulled_config(&railway_file, args.force, args.runner).await?; + let wrote_readme = write_asset_if_missing(&readme_file, include_str!("../../assets/railway-config/README.md"))?; + let wrote_skill = write_asset_if_missing(&skill_file, include_str!("../../assets/railway-config/SKILL.md"))?; println!("{}", "Railway configuration imported".green().bold()); println!("{} {}", "Updated".dimmed(), railway_file.display().to_string().cyan()); + if wrote_readme { + println!("{} {}", "Created".dimmed(), readme_file.display().to_string().cyan()); + } + if wrote_skill { + println!("{} {}", "Created".dimmed(), skill_file.display().to_string().cyan()); + } println!(); println!("{}", "Next steps".bold()); println!(" {} Review {} and remove anything you do not want managed from code.", "•".cyan(), ".railway/railway.ts".cyan()); From 843c72c41666d3bc7e2ca14ce290fa0b574bd7f5 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:17:33 +0200 Subject: [PATCH 23/40] Update Railway config support files --- assets/railway-config/README.md | 33 ++++++---- assets/railway-config/SKILL.md | 103 +++++++++++++++++++++++++------- 2 files changed, 103 insertions(+), 33 deletions(-) diff --git a/assets/railway-config/README.md b/assets/railway-config/README.md index a0a1df298..be19d43d7 100644 --- a/assets/railway-config/README.md +++ b/assets/railway-config/README.md @@ -1,37 +1,50 @@ # Railway configuration -This project contains a Railway configuration file: +This project defines its Railway infrastructure in code. ```txt .railway/railway.ts ``` -Use it to describe the desired shape of this Railway project: services, databases, buckets, volumes, domains, and environment variables. +Use this file to describe the Railway project you want: services, databases, buckets, custom domains, regions, and environment variables. -## Commands +## Common commands -Preview changes: +Create the configuration files: ```bash -railway config plan +railway config init +``` + +Import an existing Railway project into code: + +```bash +railway config pull ``` -Stage changes for review: +Preview what Railway would change: ```bash -railway config stage +railway config plan ``` -Apply changes: +Apply the planned changes: ```bash railway config apply ``` -Deploy code: +Deploy this directory: ```bash railway up ``` -If `.railway/railway.ts` has pending changes, `railway up` may ask to apply them before deploying. +If `.railway/railway.ts` has pending project changes, `railway up` previews them and asks before applying them. + +## Notes + +- `railway config plan` is safe and does not change Railway. +- `railway config apply` asks before applying unless you pass `--yes`. +- `railway up` deploys this directory when the service has no GitHub or image source. +- Secrets imported from Railway may appear as `preserve()` so they are not overwritten. diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md index 50041f1e9..f75d235f0 100644 --- a/assets/railway-config/SKILL.md +++ b/assets/railway-config/SKILL.md @@ -8,36 +8,51 @@ The source of desired Railway project state is: .railway/railway.ts ``` -## Rules +## Core rules 1. Express Railway product intent, not internal API details. 2. Do not write Railway UUIDs into `.railway/railway.ts`. -3. Do not write `EnvironmentConfigPatch`, `ServiceInstance`, or Backboard internals into source. -4. Prefer helpers like `service()`, `postgres()`, `redis()`, `bucket()`, and `volume()`. -5. Keep secrets out of source. Prefer references and Railway-managed variables. -6. After editing `.railway/railway.ts`, run `railway config plan`. -7. Do not run `railway config apply` unless the user asks. +3. Do not write `EnvironmentConfigPatch`, `ServiceInstance`, Backboard internals, or generated Railway domains into source. +4. Prefer Railway configuration helpers like `service()`, `postgres()`, `redis()`, `mysql()`, `mongo()`, `bucket()`, `github()`, and `image()`. +5. Use `service.env.VARIABLE` and `database.env.VARIABLE` for references. +6. Keep secrets out of source. Use references or `preserve()` for existing Railway-managed values. +7. Prefer product DSL names such as `domains` and `regions`; avoid internal names like `customDomains` and `multiRegionConfig`. +8. Do not add platform defaults unless the user explicitly wants them. +9. After editing `.railway/railway.ts`, run `railway config plan`. +10. Do not run `railway config apply` unless the user asks. ## Commands -Preview: +Initialize configuration files: ```bash -railway config plan +railway config init +``` + +Import current Railway state: + +```bash +railway config pull ``` -Stage: +Preview changes: ```bash -railway config stage +railway config plan ``` -Apply: +Apply changes: ```bash railway config apply ``` +Deploy this directory: + +```bash +railway up +``` + Machine-readable preview: ```bash @@ -46,7 +61,7 @@ railway config plan --json ## Authoring -Use the Railway configuration helpers: +Use Railway configuration helpers: ```ts import { @@ -57,22 +72,37 @@ import { mongo, mysql, postgres, + preserve, project, redis, service, - volume, } from "railway/iac"; ``` -Minimal service: +Minimal local service: ```ts const web = service("web", { - build: "pnpm install --frozen-lockfile && pnpm build", + build: "bun run build", + start: "NODE_ENV=production bun src/index.ts", +}); +``` + +GitHub service: + +```ts +const web = service("web", { + source: github("owner/repo", { branch: "main" }), + build: "pnpm run build", start: "pnpm start", - env: { - NODE_ENV: "production", - }, +}); +``` + +Docker image service: + +```ts +const worker = service("worker", { + source: image("ghcr.io/acme/worker:latest"), }); ``` @@ -83,8 +113,7 @@ const db = postgres("postgres"); const web = service("web", { env: { - DATABASE_URL: db.url(), - PGHOST: db.env.PGHOST, + DATABASE_URL: db.env.DATABASE_URL, }, }); ``` @@ -94,7 +123,7 @@ Service-to-service reference: ```ts const api = service("api", { env: { - INTERNAL_TOKEN: "replace-me", + INTERNAL_TOKEN: preserve(), }, }); @@ -106,7 +135,7 @@ const web = service("web", { }); ``` -Custom domain: +Custom domains: ```ts const web = service("web", { @@ -114,6 +143,23 @@ const web = service("web", { }); ``` +Regions: + +```ts +const web = service("web", { + regions: { + "us-west2": 1, + "europe-west4": 1, + }, +}); +``` + +Bucket: + +```ts +const media = bucket("media", { region: "iad" }); +``` + Project shape: ```ts @@ -121,7 +167,7 @@ export default defineRailway(() => { const db = postgres("postgres"); const web = service("web", { env: { - DATABASE_URL: db.url(), + DATABASE_URL: db.env.DATABASE_URL, }, }); @@ -131,3 +177,14 @@ export default defineRailway(() => { }); }); ``` + +## Review checklist + +Before applying changes, confirm: + +- `railway config plan` shows only expected changes. +- Secrets are not replaced with literal placeholder values. +- Existing Railway-managed variables use `preserve()` when the value should remain untouched. +- Custom domains are declared with `domains`, not networking internals. +- Regions are declared with `regions`, not `multiRegionConfig`. +- No generated Railway service domains are committed. From 9a5cdf38f345b91871bef9ea54f7da7ebf52be5f Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:21:34 +0200 Subject: [PATCH 24/40] Add Railway config skill metadata --- assets/railway-config/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md index f75d235f0..3d7f270ca 100644 --- a/assets/railway-config/SKILL.md +++ b/assets/railway-config/SKILL.md @@ -1,3 +1,8 @@ +--- +name: railway-config +description: Edit this project's Railway infrastructure-as-code configuration. Use this skill whenever the user asks to create, change, import, review, deploy, or troubleshoot Railway project infrastructure for the current repository, including services, databases, buckets, custom domains, regions, environment variables, `railway config *`, `.railway/railway.ts`, or `railway up` behavior. +--- + # Railway configuration skill Use this skill when editing this repository's Railway configuration. From c1ed1bbfeae2bdf018c441337577acb501647cea Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:29:13 +0200 Subject: [PATCH 25/40] Always show config change details --- src/commands/sync.rs | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 55b441b5a..4863610c6 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -452,18 +452,8 @@ pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, ve } else { let total = changes.len(); println!("{} {}", "Changes".bold(), format!("({total})").dimmed()); - if !verbose { - if let Some(diff) = &response.diff { - print_colored_diff(diff); - } else { - for change in changes { - print_change(change, verbose); - } - } - } else { - for change in changes { - print_change(change, verbose); - } + for change in changes { + print_change(change, verbose); } let destructive = changes @@ -481,9 +471,6 @@ pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, ve if show_next { println!(); - if !verbose && changes.iter().any(|change| change.details.as_ref().is_some_and(|details| !details.is_empty())) { - println!(" {} Run {} to show every changed field.", "•".cyan(), "railway config plan --verbose".cyan()); - } println!("{}", "Next".bold()); println!(" {} Run {} to apply these changes.", "•".cyan(), "railway config apply".cyan()); } @@ -562,7 +549,7 @@ fn format_output_value(value: &Value) -> String { } } -fn print_change(change: &Change, verbose: bool) { +fn print_change(change: &Change, _verbose: bool) { let summary = change .summary .as_deref() @@ -570,11 +557,9 @@ fn print_change(change: &Change, verbose: bool) { .unwrap_or("change"); let marker = marker_for_change(change); println!(" {} {}", marker, summary); - if verbose { - if let Some(details) = &change.details { - for detail in details { - println!(" {} {}", "└".dimmed(), detail.dimmed()); - } + if let Some(details) = &change.details { + for detail in details { + println!(" {} {}", "└".dimmed(), detail.dimmed()); } } } From 4aeee9f6ef901294d02b072a7f67f06c21a90cb1 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:29:35 +0200 Subject: [PATCH 26/40] Remove unused config diff renderer --- src/commands/sync.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 4863610c6..6dcf22691 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -564,20 +564,6 @@ fn print_change(change: &Change, _verbose: bool) { } } -fn print_colored_diff(diff: &str) { - for line in diff.lines() { - if let Some(rest) = line.strip_prefix("+ ") { - println!(" {} {}", "+".green().bold(), rest.green()); - } else if let Some(rest) = line.strip_prefix("- ") { - println!(" {} {}", "-".red().bold(), rest.red()); - } else if let Some(rest) = line.strip_prefix("~ ") { - println!(" {} {}", "~".yellow().bold(), rest.yellow()); - } else { - println!(" {line}"); - } - } -} - fn marker_for_change(change: &Change) -> colored::ColoredString { match change.kind.as_deref() { Some("resource.create") | Some("variable.set") | Some("domain.create") => "+".green().bold(), From 3a328e2bfc8969e6ef2d0d8b3b31164e261bda0f Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Tue, 2 Jun 2026 13:40:00 +0200 Subject: [PATCH 27/40] Preserve imported build details --- src/commands/config_command.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index e469133fc..097f066ae 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -372,9 +372,24 @@ fn render_service_body(resource: &crate::commands::sync::DesiredResource) -> Str fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { let Some(build) = build else { return; }; - if let Some(command) = build.get("buildCommand").and_then(|value| value.as_str()) { - lines.push(format!(" build: {:?},", command)); - return; + if let Some(object) = build.as_object() { + let non_default_keys = object + .iter() + .filter(|(key, value)| { + !matches!((key.as_str(), value), + ("builder", serde_json::Value::String(builder)) if builder == "RAILPACK" || builder == "NIXPACKS" + ) && !matches!((key.as_str(), value), + ("buildEnvironment", serde_json::Value::String(environment)) if environment == "V3" + ) + }) + .map(|(key, _)| key.as_str()) + .collect::>(); + if non_default_keys == ["buildCommand"] { + if let Some(command) = build.get("buildCommand").and_then(|value| value.as_str()) { + lines.push(format!(" build: {:?},", command)); + return; + } + } } lines.push(format!(" build: {},", ts_value(build))); } From eef2b8db557e95bc604eb734fb8463de7da854fb Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Thu, 4 Jun 2026 17:02:58 +0200 Subject: [PATCH 28/40] Send config runner context --- src/commands/sync.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 6dcf22691..43b748a92 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -293,6 +293,13 @@ async fn invoke_runner( "file": args.file.as_ref().map(|path| path.to_string_lossy().to_string()), "includeTypes": args.include_types, "pretty": false, + "context": { + "projectId": linked_project.project, + "projectName": linked_project.name, + "environmentId": linked_project.environment, + "environment": linked_project.environment_name, + "environmentName": linked_project.environment_name + }, "backboard": { "endpoint": configs.get_backboard(), "token": token, From c4b59f2f1313f35647ed7095f91a732e918d4420 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Thu, 4 Jun 2026 17:57:15 +0200 Subject: [PATCH 29/40] Improve pulled config rendering --- src/commands/config_command.rs | 136 ++++++++++++++++++++++++++------- src/commands/sync.rs | 7 ++ 2 files changed, 115 insertions(+), 28 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 097f066ae..27eb44314 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -273,7 +273,7 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St if graph.resources.iter().any(|resource| resource.r#type == "bucket") { imports.push("bucket"); } if graph.resources.iter().any(|resource| resource.source.as_ref().and_then(|source| source.get("repo")).is_some()) { imports.push("github"); } if graph.resources.iter().any(|resource| resource.source.as_ref().and_then(|source| source.get("image")).is_some() && resource.r#type == "service") { imports.push("image"); } - if graph.resources.iter().any(|resource| resource.variables.as_ref().is_some_and(|vars| vars.values().any(|value| value.get("type").and_then(|value| value.as_str()) == Some("preserve")))) { imports.push("preserve"); } + // Imported unknown secrets are preserved by default and omitted from generated source. if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("postgres")) { imports.push("postgres"); } if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("redis")) { imports.push("redis"); } if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("mysql")) { imports.push("mysql"); } @@ -284,6 +284,14 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St let mut out = format!("import {{ {} }} from \"railway/iac\";\n\n", imports.join(", ")); out.push_str("export default defineRailway(() => {\n"); + let source_aliases = shared_github_sources(graph); + for (alias, repo) in &source_aliases { + out.push_str(&format!(" const {alias} = github({:?});\n", repo)); + } + if !source_aliases.is_empty() { + out.push('\n'); + } + let mut names = Vec::new(); let import_names: std::collections::HashSet<&str> = imports.iter().copied().collect(); for resource in &graph.resources { @@ -306,7 +314,7 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St } "service" => { out.push_str(&format!(" const {var_name} = service(\"{}\"", resource.name)); - let body = render_service_body(resource); + let body = render_service_body(resource, &source_aliases); if body.is_empty() { out.push_str(");\n"); } else { @@ -327,7 +335,8 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St } } - out.push_str("\n return project(\"imported-project\", {\n"); + let project_name = graph.project.as_ref().map(|project| project.name.as_str()).unwrap_or("imported-project"); + out.push_str(&format!("\n return project({:?}, {{\n", project_name)); out.push_str(" environments: [\"production\"],\n"); out.push_str(&format!(" services: [{}],\n", names.join(", "))); out.push_str(" });\n"); @@ -335,15 +344,47 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St out } -fn render_service_body(resource: &crate::commands::sync::DesiredResource) -> String { +fn shared_github_sources(graph: &crate::commands::sync::DesiredGraph) -> std::collections::BTreeMap { + let mut counts = std::collections::BTreeMap::::new(); + for resource in &graph.resources { + if resource.r#type != "service" { continue; } + if let Some(repo) = resource.source.as_ref().and_then(|source| source.get("repo")).and_then(|value| value.as_str()) { + *counts.entry(repo.to_string()).or_default() += 1; + } + } + + let reserved = std::collections::HashSet::from(["defineRailway", "project", "service", "github", "image", "bucket"]); + let mut used = Vec::new(); + counts.into_iter() + .filter(|(_, count)| *count > 1) + .map(|(repo, _)| { + let repo_name = repo.rsplit('/').next().unwrap_or(&repo); + let alias = unique_resource_ident(repo_name, "source", &reserved, &used); + used.push(alias.clone()); + (alias, repo) + }) + .collect() +} + +fn render_service_body(resource: &crate::commands::sync::DesiredResource, source_aliases: &std::collections::BTreeMap) -> String { let mut lines = Vec::new(); if let Some(source) = &resource.source { if let Some(repo) = source.get("repo").and_then(|value| value.as_str()) { - let mut args = format!("{:?}", repo); - if let Some(branch) = source.get("branch").and_then(|value| value.as_str()) { - args.push_str(&format!(", {{ branch: {:?} }}", branch)); + let alias = source_aliases.iter().find_map(|(alias, shared_repo)| (shared_repo == repo).then_some(alias)); + let root = source.get("rootDirectory").and_then(|value| value.as_str()).filter(|value| !value.is_empty()); + let branch = source.get("branch").and_then(|value| value.as_str()).filter(|branch| *branch != "main"); + if let Some(alias) = alias { + lines.push(format!(" source: {alias},")); + if let Some(root) = root { + lines.push(format!(" root: {:?},", root)); + } + } else { + let mut options = Vec::new(); + if let Some(branch) = branch { options.push(format!("branch: {:?}", branch)); } + if let Some(root) = root { options.push(format!("rootDirectory: {:?}", root)); } + let args = if options.is_empty() { format!("{:?}", repo) } else { format!("{:?}, {{ {} }}", repo, options.join(", ")) }; + lines.push(format!(" source: github({args}),")); } - lines.push(format!(" source: github({args}),")); } else if let Some(image_name) = source.get("image").and_then(|value| value.as_str()) { lines.push(format!(" source: image({:?}),", image_name)); } @@ -351,25 +392,41 @@ fn render_service_body(resource: &crate::commands::sync::DesiredResource) -> Str render_build(resource.build.as_ref(), &mut lines); render_deploy(resource.deploy.as_ref(), &mut lines); render_networking(resource.networking.as_ref(), &mut lines); - if let Some(vars) = &resource.variables { - if !vars.is_empty() { - lines.push(" env: {".to_string()); - for (key, value) in vars { - if value.get("type").and_then(|value| value.as_str()) == Some("preserve") { - lines.push(format!(" {key}: preserve(),")); - } else if let Some(literal) = value.get("value").and_then(|value| value.as_str()) { - lines.push(format!(" {key}: {:?},", literal)); - } else if let Some(output) = value.get("output").and_then(|value| value.as_str()) { - lines.push(format!(" {key}: \"${{{{{output}}}}}\",")); - } - } - lines.push(" },".to_string()); - } - } + render_variables(resource.variables.as_ref(), &mut lines); if lines.is_empty() { return String::new(); } format!("{{\n{}\n }}", lines.join("\n")) } +fn render_variables(vars: Option<&serde_json::Map>, lines: &mut Vec) { + let Some(vars) = vars else { return; }; + let mut entries = vars.iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + let rendered = entries + .into_iter() + .filter_map(|(key, value)| { + if value.get("type").and_then(|value| value.as_str()) == Some("preserve") { + return None; + } + if let Some(literal) = value.get("value").and_then(|value| value.as_str()) { + return Some(format!(" {}: {:?},", ts_key(key), literal)); + } + if let (Some(resource), Some(output)) = ( + value.get("resource").and_then(|value| value.as_str()), + value.get("output").and_then(|value| value.as_str()), + ) { + let name = resource.split('.').skip(1).collect::>().join("."); + return Some(format!(" {}: /* {}.{} */ \"${{{{{}}}}}\",", ts_key(key), name, output, output)); + } + None + }) + .collect::>(); + + if rendered.is_empty() { return; } + lines.push(" env: {".to_string()); + lines.extend(rendered); + lines.push(" },".to_string()); +} + fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { let Some(build) = build else { return; }; if let Some(object) = build.as_object() { @@ -384,6 +441,9 @@ fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { }) .map(|(key, _)| key.as_str()) .collect::>(); + if non_default_keys.is_empty() { + return; + } if non_default_keys == ["buildCommand"] { if let Some(command) = build.get("buildCommand").and_then(|value| value.as_str()) { lines.push(format!(" build: {:?},", command)); @@ -391,7 +451,9 @@ fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { } } } - lines.push(format!(" build: {},", ts_value(build))); + if !is_empty_object(build) { + lines.push(format!(" build: {},", ts_value(build))); + } } fn render_deploy(deploy: Option<&serde_json::Value>, lines: &mut Vec) { @@ -408,7 +470,7 @@ fn render_deploy(deploy: Option<&serde_json::Value>, lines: &mut Vec) { lines.push(format!(" healthcheckTimeout: {},", ts_value(&timeout))); } if let Some(regions) = remaining.remove("multiRegionConfig") { - lines.push(format!(" regions: {},", render_regions(®ions))); + lines.push(format!(" replicas: {},", render_replicas(®ions))); } if remaining.get("ipv6EgressEnabled").and_then(|value| value.as_bool()) == Some(false) { @@ -426,6 +488,20 @@ fn render_deploy(deploy: Option<&serde_json::Value>, lines: &mut Vec) { } } +fn render_replicas(value: &serde_json::Value) -> String { + let Some(regions) = value.as_object() else { return ts_value(value); }; + let active = regions.iter() + .filter_map(|(region, config)| { + let replicas = config.get("numReplicas").and_then(|value| value.as_u64())?; + Some((region, config, replicas)) + }) + .collect::>(); + if active.len() == 1 { + return active[0].2.to_string(); + } + render_regions(value) +} + fn render_regions(value: &serde_json::Value) -> String { let Some(regions) = value.as_object() else { return ts_value(value); }; let rendered = regions.iter().map(|(region, config)| { @@ -435,7 +511,7 @@ fn render_regions(value: &serde_json::Value) -> String { (Some(replicas), None) => replicas.to_string(), _ => { let mut parts = Vec::new(); - if let Some(replicas) = replicas { parts.push(format!("replicas: {replicas}")); } + if let Some(replicas) = replicas { parts.push(format!("count: {replicas}")); } if let Some(stacker) = stacker { parts.push(format!("stacker: {:?}", stacker)); } format!("{{ {} }}", parts.join(", ")) } @@ -452,7 +528,7 @@ fn render_networking(networking: Option<&serde_json::Value>, lines: &mut Vec, lines: &mut Vec bool { + value.as_object().is_some_and(|object| object.is_empty()) +} + fn ts_value(value: &serde_json::Value) -> String { match value { serde_json::Value::Object(object) => { @@ -589,7 +669,7 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { if let Some(start) = start { out.push_str(&format!(" start: {:?},\n", start)); } - out.push_str(" env: {\n NODE_ENV: \"production\",\n },\n"); + out.push_str(" });\n\n"); out.push_str(&format!(" return project(\"{project_name}\", {{\n")); out.push_str(" environments: [\"production\"],\n services: [web],\n });\n});\n"); diff --git a/src/commands/sync.rs b/src/commands/sync.rs index 43b748a92..d4ca7798b 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -118,9 +118,16 @@ struct ChangeOperationResult { #[derive(Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct DesiredGraph { + pub(super) project: Option, pub(super) resources: Vec, } +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DesiredProject { + pub(super) name: String, +} + #[derive(Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct DesiredResource { From 6280f3700d3a812763e404dd75e492030235b066 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 09:10:51 +0200 Subject: [PATCH 30/40] Update config support assets for v0 DSL --- assets/railway-config/README.md | 6 ++++-- assets/railway-config/SKILL.md | 35 ++++++++++++++++++++++++--------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/assets/railway-config/README.md b/assets/railway-config/README.md index be19d43d7..fa4ec3035 100644 --- a/assets/railway-config/README.md +++ b/assets/railway-config/README.md @@ -6,7 +6,7 @@ This project defines its Railway infrastructure in code. .railway/railway.ts ``` -Use this file to describe the Railway project you want: services, databases, buckets, custom domains, regions, and environment variables. +Use this file to describe the Railway project you want: services, databases, buckets, custom domains, replicas, groups, and environment variables. ## Common commands @@ -47,4 +47,6 @@ If `.railway/railway.ts` has pending project changes, `railway up` previews them - `railway config plan` is safe and does not change Railway. - `railway config apply` asks before applying unless you pass `--yes`. - `railway up` deploys this directory when the service has no GitHub or image source. -- Secrets imported from Railway may appear as `preserve()` so they are not overwritten. +- Use `replicas` for scaling; advanced placement can still specify region names. +- Use `group("Name", [resources])` to keep large projects organized on the Railway canvas. +- Secrets imported from Railway may be omitted or represented with `preserve()` so they are not overwritten. diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md index 3d7f270ca..c6cfcad25 100644 --- a/assets/railway-config/SKILL.md +++ b/assets/railway-config/SKILL.md @@ -1,6 +1,6 @@ --- name: railway-config -description: Edit this project's Railway infrastructure-as-code configuration. Use this skill whenever the user asks to create, change, import, review, deploy, or troubleshoot Railway project infrastructure for the current repository, including services, databases, buckets, custom domains, regions, environment variables, `railway config *`, `.railway/railway.ts`, or `railway up` behavior. +description: Edit this project's Railway infrastructure-as-code configuration. Use this skill whenever the user asks to create, change, import, review, deploy, or troubleshoot Railway project infrastructure for the current repository, including services, databases, buckets, custom domains, replicas/regions, groups, environment variables, `railway config *`, `.railway/railway.ts`, or `railway up` behavior. --- # Railway configuration skill @@ -18,10 +18,10 @@ The source of desired Railway project state is: 1. Express Railway product intent, not internal API details. 2. Do not write Railway UUIDs into `.railway/railway.ts`. 3. Do not write `EnvironmentConfigPatch`, `ServiceInstance`, Backboard internals, or generated Railway domains into source. -4. Prefer Railway configuration helpers like `service()`, `postgres()`, `redis()`, `mysql()`, `mongo()`, `bucket()`, `github()`, and `image()`. +4. Prefer Railway configuration helpers like `service()`, `postgres()`, `redis()`, `mysql()`, `mongo()`, `bucket()`, `group()`, `github()`, and `image()`. 5. Use `service.env.VARIABLE` and `database.env.VARIABLE` for references. -6. Keep secrets out of source. Use references or `preserve()` for existing Railway-managed values. -7. Prefer product DSL names such as `domains` and `regions`; avoid internal names like `customDomains` and `multiRegionConfig`. +6. Keep secrets out of source. Prefer omitting unknown imported secrets; use `preserve()` when an existing Railway-managed value must be explicitly retained. +7. Prefer product DSL names such as `domains`, `replicas`, and `group`; avoid internal names like `customDomains` and `multiRegionConfig`. 8. Do not add platform defaults unless the user explicitly wants them. 9. After editing `.railway/railway.ts`, run `railway config plan`. 10. Do not run `railway config apply` unless the user asks. @@ -73,6 +73,7 @@ import { bucket, defineRailway, github, + group, image, mongo, mysql, @@ -148,17 +149,33 @@ const web = service("web", { }); ``` -Regions: +Replicas: ```ts const web = service("web", { - regions: { - "us-west2": 1, + replicas: 3, +}); +``` + +Advanced placement: + +```ts +const web = service("web", { + replicas: { + "us-west2": 2, "europe-west4": 1, }, }); ``` +Groups: + +```ts +const api = service("api"); +const worker = service("worker"); +const backend = group("Backend", [api, worker]); +``` + Bucket: ```ts @@ -189,7 +206,7 @@ Before applying changes, confirm: - `railway config plan` shows only expected changes. - Secrets are not replaced with literal placeholder values. -- Existing Railway-managed variables use `preserve()` when the value should remain untouched. +- Existing Railway-managed variables are omitted or use `preserve()` when the value should remain untouched. - Custom domains are declared with `domains`, not networking internals. -- Regions are declared with `regions`, not `multiRegionConfig`. +- Scaling is declared with `replicas`, not `multiRegionConfig`. - No generated Railway service domains are committed. From cb5fca7a8d225906c2ba88ad3ef1ec80c040210d Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 09:21:59 +0200 Subject: [PATCH 31/40] Document railway.json migration guard --- assets/railway-config/README.md | 1 + assets/railway-config/SKILL.md | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/railway-config/README.md b/assets/railway-config/README.md index fa4ec3035..1a67797d8 100644 --- a/assets/railway-config/README.md +++ b/assets/railway-config/README.md @@ -47,6 +47,7 @@ If `.railway/railway.ts` has pending project changes, `railway up` previews them - `railway config plan` is safe and does not change Railway. - `railway config apply` asks before applying unless you pass `--yes`. - `railway up` deploys this directory when the service has no GitHub or image source. +- Services already managed by `railway.json` / `railway.toml` must be migrated before `.railway/railway.ts` can manage them. - Use `replicas` for scaling; advanced placement can still specify region names. - Use `group("Name", [resources])` to keep large projects organized on the Railway canvas. - Secrets imported from Railway may be omitted or represented with `preserve()` so they are not overwritten. diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md index c6cfcad25..c04d0ddd1 100644 --- a/assets/railway-config/SKILL.md +++ b/assets/railway-config/SKILL.md @@ -23,8 +23,9 @@ The source of desired Railway project state is: 6. Keep secrets out of source. Prefer omitting unknown imported secrets; use `preserve()` when an existing Railway-managed value must be explicitly retained. 7. Prefer product DSL names such as `domains`, `replicas`, and `group`; avoid internal names like `customDomains` and `multiRegionConfig`. 8. Do not add platform defaults unless the user explicitly wants them. -9. After editing `.railway/railway.ts`, run `railway config plan`. -10. Do not run `railway config apply` unless the user asks. +9. Do not manage a service from both `.railway/railway.ts` and `railway.json` / `railway.toml`; migrate the repo config first. +10. After editing `.railway/railway.ts`, run `railway config plan`. +11. Do not run `railway config apply` unless the user asks. ## Commands From 0a472fd06587ef1a8e4f663afdcb3bfaba2ded0f Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 09:51:21 +0200 Subject: [PATCH 32/40] Stop generating environment declarations --- assets/railway-config/SKILL.md | 1 - src/commands/config_command.rs | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md index c04d0ddd1..095651bfa 100644 --- a/assets/railway-config/SKILL.md +++ b/assets/railway-config/SKILL.md @@ -195,7 +195,6 @@ export default defineRailway(() => { }); return project("my-app", { - environments: ["production"], services: [db, web], }); }); diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 27eb44314..5aa36bd85 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -337,7 +337,6 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St let project_name = graph.project.as_ref().map(|project| project.name.as_str()).unwrap_or("imported-project"); out.push_str(&format!("\n return project({:?}, {{\n", project_name)); - out.push_str(" environments: [\"production\"],\n"); out.push_str(&format!(" services: [{}],\n", names.join(", "))); out.push_str(" });\n"); out.push_str("});\n"); @@ -672,7 +671,7 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { out.push_str(" });\n\n"); out.push_str(&format!(" return project(\"{project_name}\", {{\n")); - out.push_str(" environments: [\"production\"],\n services: [web],\n });\n});\n"); + out.push_str(" services: [web],\n });\n});\n"); out } @@ -734,7 +733,6 @@ export default defineRailway(() => {{ }}); return project("{project_name}", {{ - environments: ["production"], services: [web], }}); }}); From 3481072c2492fdf3b74435c2213a1c3167dc0b01 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 10:19:31 +0200 Subject: [PATCH 33/40] Generate resources in Railway config --- assets/railway-config/SKILL.md | 2 +- src/commands/config_command.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/railway-config/SKILL.md b/assets/railway-config/SKILL.md index 095651bfa..3c8f69c87 100644 --- a/assets/railway-config/SKILL.md +++ b/assets/railway-config/SKILL.md @@ -195,7 +195,7 @@ export default defineRailway(() => { }); return project("my-app", { - services: [db, web], + resources: [db, web], }); }); ``` diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 5aa36bd85..75a734c8b 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -337,7 +337,7 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St let project_name = graph.project.as_ref().map(|project| project.name.as_str()).unwrap_or("imported-project"); out.push_str(&format!("\n return project({:?}, {{\n", project_name)); - out.push_str(&format!(" services: [{}],\n", names.join(", "))); + out.push_str(&format!(" resources: [{}],\n", names.join(", "))); out.push_str(" });\n"); out.push_str("});\n"); out @@ -671,7 +671,7 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { out.push_str(" });\n\n"); out.push_str(&format!(" return project(\"{project_name}\", {{\n")); - out.push_str(" services: [web],\n });\n});\n"); + out.push_str(" resources: [web],\n });\n});\n"); out } @@ -733,7 +733,7 @@ export default defineRailway(() => {{ }}); return project("{project_name}", {{ - services: [web], + resources: [web], }}); }}); "# From d900326a955ed9da203c2642f20c2517b06fc47d Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 10:45:07 +0200 Subject: [PATCH 34/40] fmt --- src/commands/config_command.rs | 476 ++++++++++++++++++++++++++------- src/commands/link.rs | 36 ++- src/commands/sync.rs | 105 ++++++-- src/commands/up.rs | 17 +- 4 files changed, 500 insertions(+), 134 deletions(-) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 75a734c8b..0446ce4ec 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -1,4 +1,8 @@ -use std::{fs, path::{Path, PathBuf}, process::Command as ProcessCommand}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command as ProcessCommand, +}; use is_terminal::IsTerminal; @@ -73,7 +77,9 @@ enum InitMode { impl std::fmt::Display for InitMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - InitMode::GenerateFromRepo => write!(f, "Scan this directory and suggest a basic setup"), + InitMode::GenerateFromRepo => { + write!(f, "Scan this directory and suggest a basic setup") + } InitMode::ImportFromRailway => write!(f, "Import an existing Railway project"), InitMode::MinimalFile => write!(f, "Create an empty configuration file"), } @@ -109,7 +115,9 @@ struct PullArgs { pub async fn command(args: Args) -> Result<()> { match args.command { Command::Plan(args) => run_sync(args, false, false).await, - Command::Stage(_args) => bail!("Staged Railway configuration changes are not available yet. Run `railway config plan` to preview changes or `railway config apply` to apply them."), + Command::Stage(_args) => bail!( + "Staged Railway configuration changes are not available yet. Run `railway config plan` to preview changes or `railway config apply` to apply them." + ), Command::Apply(args) => run_sync(args, false, true).await, Command::Init(args) => init_config(args).await, Command::Pull(args) => pull_config(args).await, @@ -152,24 +160,63 @@ async fn init_config(args: InitArgs) -> Result<()> { }; match init_mode { - InitMode::GenerateFromRepo => write_new(&railway_file, &railway_ts_from_repo(&cwd, &project_name), args.force)?, + InitMode::GenerateFromRepo => write_new( + &railway_file, + &railway_ts_from_repo(&cwd, &project_name), + args.force, + )?, InitMode::ImportFromRailway => write_pulled_config(&railway_file, args.force, None).await?, InitMode::MinimalFile => write_new(&railway_file, &railway_ts(&project_name), args.force)?, } - write_new(&readme_file, include_str!("../../assets/railway-config/README.md"), args.force)?; - let wrote_skill = write_asset_if_missing(&skill_file, include_str!("../../assets/railway-config/SKILL.md"))?; + write_new( + &readme_file, + include_str!("../../assets/railway-config/README.md"), + args.force, + )?; + let wrote_skill = write_asset_if_missing( + &skill_file, + include_str!("../../assets/railway-config/SKILL.md"), + )?; println!("{}", "Railway configuration initialized".green().bold()); - println!("{} {}", match init_mode { InitMode::ImportFromRailway => "Imported", _ => "Created" }.dimmed(), railway_file.display().to_string().cyan()); - println!("{} {}", "Created".dimmed(), readme_file.display().to_string().cyan()); + println!( + "{} {}", + match init_mode { + InitMode::ImportFromRailway => "Imported", + _ => "Created", + } + .dimmed(), + railway_file.display().to_string().cyan() + ); + println!( + "{} {}", + "Created".dimmed(), + readme_file.display().to_string().cyan() + ); if wrote_skill { - println!("{} {}", "Created".dimmed(), skill_file.display().to_string().cyan()); + println!( + "{} {}", + "Created".dimmed(), + skill_file.display().to_string().cyan() + ); } println!(); println!("{}", "Next steps".bold()); - println!(" {} Edit {} to describe your Railway project.", "•".cyan(), ".railway/railway.ts".cyan()); - println!(" {} Run {} to preview changes.", "•".cyan(), "railway config plan".cyan()); - println!(" {} Run {} to apply them.", "•".cyan(), "railway config apply".cyan()); + println!( + " {} Edit {} to describe your Railway project.", + "•".cyan(), + ".railway/railway.ts".cyan() + ); + println!( + " {} Run {} to preview changes.", + "•".cyan(), + "railway config plan".cyan() + ); + println!( + " {} Run {} to apply them.", + "•".cyan(), + "railway config apply".cyan() + ); Ok(()) } @@ -192,7 +239,10 @@ fn write_asset_if_missing(path: &Path, contents: &str) -> Result { fn write_new(path: &Path, contents: &str, force: bool) -> Result<()> { if path.exists() && !force { - bail!("{} already exists. Re-run with --force to overwrite it.", path.display()); + bail!( + "{} already exists. Re-run with --force to overwrite it.", + path.display() + ); } fs::write(path, contents).with_context(|| format!("Failed to write {}", path.display())) } @@ -201,7 +251,11 @@ async fn pull_config(args: PullArgs) -> Result<()> { let cwd = std::env::current_dir().context("Unable to get current directory")?; let railway_file = cwd.join(".railway").join("railway.ts"); let readme_file = cwd.join(".railway").join("README.md"); - let skill_file = cwd.join(".agents").join("skills").join("railway-config").join("SKILL.md"); + let skill_file = cwd + .join(".agents") + .join("skills") + .join("railway-config") + .join("SKILL.md"); if args.json { let graph = load_current_graph(args.runner).await?; @@ -212,23 +266,52 @@ async fn pull_config(args: PullArgs) -> Result<()> { create_parent(&railway_file)?; create_parent(&skill_file)?; write_pulled_config(&railway_file, args.force, args.runner).await?; - let wrote_readme = write_asset_if_missing(&readme_file, include_str!("../../assets/railway-config/README.md"))?; - let wrote_skill = write_asset_if_missing(&skill_file, include_str!("../../assets/railway-config/SKILL.md"))?; + let wrote_readme = write_asset_if_missing( + &readme_file, + include_str!("../../assets/railway-config/README.md"), + )?; + let wrote_skill = write_asset_if_missing( + &skill_file, + include_str!("../../assets/railway-config/SKILL.md"), + )?; println!("{}", "Railway configuration imported".green().bold()); - println!("{} {}", "Updated".dimmed(), railway_file.display().to_string().cyan()); + println!( + "{} {}", + "Updated".dimmed(), + railway_file.display().to_string().cyan() + ); if wrote_readme { - println!("{} {}", "Created".dimmed(), readme_file.display().to_string().cyan()); + println!( + "{} {}", + "Created".dimmed(), + readme_file.display().to_string().cyan() + ); } if wrote_skill { - println!("{} {}", "Created".dimmed(), skill_file.display().to_string().cyan()); + println!( + "{} {}", + "Created".dimmed(), + skill_file.display().to_string().cyan() + ); } println!(); println!("{}", "Next steps".bold()); - println!(" {} Review {} and remove anything you do not want managed from code.", "•".cyan(), ".railway/railway.ts".cyan()); - println!(" {} Run {} to verify it matches Railway.", "•".cyan(), "railway config plan".cyan()); + println!( + " {} Review {} and remove anything you do not want managed from code.", + "•".cyan(), + ".railway/railway.ts".cyan() + ); + println!( + " {} Run {} to verify it matches Railway.", + "•".cyan(), + "railway config plan".cyan() + ); if args.agent { - println!(" {} Ask your agent to clean this import into idiomatic Railway configuration.", "•".cyan()); + println!( + " {} Ask your agent to clean this import into idiomatic Railway configuration.", + "•".cyan() + ); } Ok(()) @@ -265,23 +348,67 @@ async fn load_current_graph(runner: Option) -> Result String { let mut imports = vec!["defineRailway", "project", "service"]; - if graph.resources.iter().any(|resource| resource.r#type == "bucket") { imports.push("bucket"); } - if graph.resources.iter().any(|resource| resource.source.as_ref().and_then(|source| source.get("repo")).is_some()) { imports.push("github"); } - if graph.resources.iter().any(|resource| resource.source.as_ref().and_then(|source| source.get("image")).is_some() && resource.r#type == "service") { imports.push("image"); } + if graph + .resources + .iter() + .any(|resource| resource.r#type == "bucket") + { + imports.push("bucket"); + } + if graph.resources.iter().any(|resource| { + resource + .source + .as_ref() + .and_then(|source| source.get("repo")) + .is_some() + }) { + imports.push("github"); + } + if graph.resources.iter().any(|resource| { + resource + .source + .as_ref() + .and_then(|source| source.get("image")) + .is_some() + && resource.r#type == "service" + }) { + imports.push("image"); + } // Imported unknown secrets are preserved by default and omitted from generated source. - if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("postgres")) { imports.push("postgres"); } - if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("redis")) { imports.push("redis"); } - if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("mysql")) { imports.push("mysql"); } - if graph.resources.iter().any(|resource| resource.r#type == "database" && resource.engine.as_deref() == Some("mongo")) { imports.push("mongo"); } + if graph.resources.iter().any(|resource| { + resource.r#type == "database" && resource.engine.as_deref() == Some("postgres") + }) { + imports.push("postgres"); + } + if graph.resources.iter().any(|resource| { + resource.r#type == "database" && resource.engine.as_deref() == Some("redis") + }) { + imports.push("redis"); + } + if graph.resources.iter().any(|resource| { + resource.r#type == "database" && resource.engine.as_deref() == Some("mysql") + }) { + imports.push("mysql"); + } + if graph.resources.iter().any(|resource| { + resource.r#type == "database" && resource.engine.as_deref() == Some("mongo") + }) { + imports.push("mongo"); + } imports.sort(); imports.dedup(); - let mut out = format!("import {{ {} }} from \"railway/iac\";\n\n", imports.join(", ")); + let mut out = format!( + "import {{ {} }} from \"railway/iac\";\n\n", + imports.join(", ") + ); out.push_str("export default defineRailway(() => {\n"); let source_aliases = shared_github_sources(graph); @@ -295,7 +422,8 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St let mut names = Vec::new(); let import_names: std::collections::HashSet<&str> = imports.iter().copied().collect(); for resource in &graph.resources { - let var_name = unique_resource_ident(&resource.name, &resource.r#type, &import_names, &names); + let var_name = + unique_resource_ident(&resource.name, &resource.r#type, &import_names, &names); match resource.r#type.as_str() { "database" => { let helper = match resource.engine.as_deref() { @@ -306,14 +434,23 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St _ => "service", }; if helper == "service" { - out.push_str(&format!(" const {var_name} = service(\"{}\");\n", resource.name)); + out.push_str(&format!( + " const {var_name} = service(\"{}\");\n", + resource.name + )); } else { - out.push_str(&format!(" const {var_name} = {helper}(\"{}\");\n", resource.name)); + out.push_str(&format!( + " const {var_name} = {helper}(\"{}\");\n", + resource.name + )); } names.push(var_name); } "service" => { - out.push_str(&format!(" const {var_name} = service(\"{}\"", resource.name)); + out.push_str(&format!( + " const {var_name} = service(\"{}\"", + resource.name + )); let body = render_service_body(resource, &source_aliases); if body.is_empty() { out.push_str(");\n"); @@ -325,9 +462,15 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St "bucket" => { let config = resource.config.as_ref().map(ts_value).unwrap_or_default(); if config.is_empty() { - out.push_str(&format!(" const {var_name} = bucket(\"{}\");\n", resource.name)); + out.push_str(&format!( + " const {var_name} = bucket(\"{}\");\n", + resource.name + )); } else { - out.push_str(&format!(" const {var_name} = bucket(\"{}\", {config});\n", resource.name)); + out.push_str(&format!( + " const {var_name} = bucket(\"{}\", {config});\n", + resource.name + )); } names.push(var_name); } @@ -335,7 +478,11 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St } } - let project_name = graph.project.as_ref().map(|project| project.name.as_str()).unwrap_or("imported-project"); + let project_name = graph + .project + .as_ref() + .map(|project| project.name.as_str()) + .unwrap_or("imported-project"); out.push_str(&format!("\n return project({:?}, {{\n", project_name)); out.push_str(&format!(" resources: [{}],\n", names.join(", "))); out.push_str(" });\n"); @@ -343,18 +490,35 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St out } -fn shared_github_sources(graph: &crate::commands::sync::DesiredGraph) -> std::collections::BTreeMap { +fn shared_github_sources( + graph: &crate::commands::sync::DesiredGraph, +) -> std::collections::BTreeMap { let mut counts = std::collections::BTreeMap::::new(); for resource in &graph.resources { - if resource.r#type != "service" { continue; } - if let Some(repo) = resource.source.as_ref().and_then(|source| source.get("repo")).and_then(|value| value.as_str()) { + if resource.r#type != "service" { + continue; + } + if let Some(repo) = resource + .source + .as_ref() + .and_then(|source| source.get("repo")) + .and_then(|value| value.as_str()) + { *counts.entry(repo.to_string()).or_default() += 1; } } - let reserved = std::collections::HashSet::from(["defineRailway", "project", "service", "github", "image", "bucket"]); + let reserved = std::collections::HashSet::from([ + "defineRailway", + "project", + "service", + "github", + "image", + "bucket", + ]); let mut used = Vec::new(); - counts.into_iter() + counts + .into_iter() .filter(|(_, count)| *count > 1) .map(|(repo, _)| { let repo_name = repo.rsplit('/').next().unwrap_or(&repo); @@ -365,13 +529,24 @@ fn shared_github_sources(graph: &crate::commands::sync::DesiredGraph) -> std::co .collect() } -fn render_service_body(resource: &crate::commands::sync::DesiredResource, source_aliases: &std::collections::BTreeMap) -> String { +fn render_service_body( + resource: &crate::commands::sync::DesiredResource, + source_aliases: &std::collections::BTreeMap, +) -> String { let mut lines = Vec::new(); if let Some(source) = &resource.source { if let Some(repo) = source.get("repo").and_then(|value| value.as_str()) { - let alias = source_aliases.iter().find_map(|(alias, shared_repo)| (shared_repo == repo).then_some(alias)); - let root = source.get("rootDirectory").and_then(|value| value.as_str()).filter(|value| !value.is_empty()); - let branch = source.get("branch").and_then(|value| value.as_str()).filter(|branch| *branch != "main"); + let alias = source_aliases + .iter() + .find_map(|(alias, shared_repo)| (shared_repo == repo).then_some(alias)); + let root = source + .get("rootDirectory") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()); + let branch = source + .get("branch") + .and_then(|value| value.as_str()) + .filter(|branch| *branch != "main"); if let Some(alias) = alias { lines.push(format!(" source: {alias},")); if let Some(root) = root { @@ -379,9 +554,17 @@ fn render_service_body(resource: &crate::commands::sync::DesiredResource, source } } else { let mut options = Vec::new(); - if let Some(branch) = branch { options.push(format!("branch: {:?}", branch)); } - if let Some(root) = root { options.push(format!("rootDirectory: {:?}", root)); } - let args = if options.is_empty() { format!("{:?}", repo) } else { format!("{:?}, {{ {} }}", repo, options.join(", ")) }; + if let Some(branch) = branch { + options.push(format!("branch: {:?}", branch)); + } + if let Some(root) = root { + options.push(format!("rootDirectory: {:?}", root)); + } + let args = if options.is_empty() { + format!("{:?}", repo) + } else { + format!("{:?}, {{ {} }}", repo, options.join(", ")) + }; lines.push(format!(" source: github({args}),")); } } else if let Some(image_name) = source.get("image").and_then(|value| value.as_str()) { @@ -392,12 +575,19 @@ fn render_service_body(resource: &crate::commands::sync::DesiredResource, source render_deploy(resource.deploy.as_ref(), &mut lines); render_networking(resource.networking.as_ref(), &mut lines); render_variables(resource.variables.as_ref(), &mut lines); - if lines.is_empty() { return String::new(); } + if lines.is_empty() { + return String::new(); + } format!("{{\n{}\n }}", lines.join("\n")) } -fn render_variables(vars: Option<&serde_json::Map>, lines: &mut Vec) { - let Some(vars) = vars else { return; }; +fn render_variables( + vars: Option<&serde_json::Map>, + lines: &mut Vec, +) { + let Some(vars) = vars else { + return; + }; let mut entries = vars.iter().collect::>(); entries.sort_by(|(left, _), (right, _)| left.cmp(right)); let rendered = entries @@ -414,20 +604,30 @@ fn render_variables(vars: Option<&serde_json::Map>, l value.get("output").and_then(|value| value.as_str()), ) { let name = resource.split('.').skip(1).collect::>().join("."); - return Some(format!(" {}: /* {}.{} */ \"${{{{{}}}}}\",", ts_key(key), name, output, output)); + return Some(format!( + " {}: /* {}.{} */ \"${{{{{}}}}}\",", + ts_key(key), + name, + output, + output + )); } None }) .collect::>(); - if rendered.is_empty() { return; } + if rendered.is_empty() { + return; + } lines.push(" env: {".to_string()); lines.extend(rendered); lines.push(" },".to_string()); } fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { - let Some(build) = build else { return; }; + let Some(build) = build else { + return; + }; if let Some(object) = build.as_object() { let non_default_keys = object .iter() @@ -456,13 +656,21 @@ fn render_build(build: Option<&serde_json::Value>, lines: &mut Vec) { } fn render_deploy(deploy: Option<&serde_json::Value>, lines: &mut Vec) { - let Some(deploy) = deploy.and_then(|value| value.as_object()) else { return; }; + let Some(deploy) = deploy.and_then(|value| value.as_object()) else { + return; + }; let mut remaining = deploy.clone(); - if let Some(start) = remaining.remove("startCommand").and_then(|value| value.as_str().map(ToOwned::to_owned)) { + if let Some(start) = remaining + .remove("startCommand") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + { lines.push(format!(" start: {:?},", start)); } - if let Some(healthcheck) = remaining.remove("healthcheckPath").and_then(|value| value.as_str().map(ToOwned::to_owned)) { + if let Some(healthcheck) = remaining + .remove("healthcheckPath") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + { lines.push(format!(" healthcheck: {:?},", healthcheck)); } if let Some(timeout) = remaining.remove("healthcheckTimeout") { @@ -472,24 +680,38 @@ fn render_deploy(deploy: Option<&serde_json::Value>, lines: &mut Vec) { lines.push(format!(" replicas: {},", render_replicas(®ions))); } - if remaining.get("ipv6EgressEnabled").and_then(|value| value.as_bool()) == Some(false) { + if remaining + .get("ipv6EgressEnabled") + .and_then(|value| value.as_bool()) + == Some(false) + { remaining.remove("ipv6EgressEnabled"); } if remaining.get("runtime").and_then(|value| value.as_str()) == Some("V2") { remaining.remove("runtime"); } - if remaining.get("useLegacyStacker").and_then(|value| value.as_bool()) == Some(false) { + if remaining + .get("useLegacyStacker") + .and_then(|value| value.as_bool()) + == Some(false) + { remaining.remove("useLegacyStacker"); } if !remaining.is_empty() { - lines.push(format!(" deploy: {},", ts_value(&serde_json::Value::Object(remaining)))); + lines.push(format!( + " deploy: {},", + ts_value(&serde_json::Value::Object(remaining)) + )); } } fn render_replicas(value: &serde_json::Value) -> String { - let Some(regions) = value.as_object() else { return ts_value(value); }; - let active = regions.iter() + let Some(regions) = value.as_object() else { + return ts_value(value); + }; + let active = regions + .iter() .filter_map(|(region, config)| { let replicas = config.get("numReplicas").and_then(|value| value.as_u64())?; Some((region, config, replicas)) @@ -502,45 +724,69 @@ fn render_replicas(value: &serde_json::Value) -> String { } fn render_regions(value: &serde_json::Value) -> String { - let Some(regions) = value.as_object() else { return ts_value(value); }; - let rendered = regions.iter().map(|(region, config)| { - let replicas = config.get("numReplicas").and_then(|value| value.as_u64()); - let stacker = config.get("stackerAssignment").and_then(|value| value.as_str()); - let value = match (replicas, stacker) { - (Some(replicas), None) => replicas.to_string(), - _ => { - let mut parts = Vec::new(); - if let Some(replicas) = replicas { parts.push(format!("count: {replicas}")); } - if let Some(stacker) = stacker { parts.push(format!("stacker: {:?}", stacker)); } - format!("{{ {} }}", parts.join(", ")) - } - }; - format!("{:?}: {value}", region) - }).collect::>().join(", "); + let Some(regions) = value.as_object() else { + return ts_value(value); + }; + let rendered = regions + .iter() + .map(|(region, config)| { + let replicas = config.get("numReplicas").and_then(|value| value.as_u64()); + let stacker = config + .get("stackerAssignment") + .and_then(|value| value.as_str()); + let value = match (replicas, stacker) { + (Some(replicas), None) => replicas.to_string(), + _ => { + let mut parts = Vec::new(); + if let Some(replicas) = replicas { + parts.push(format!("count: {replicas}")); + } + if let Some(stacker) = stacker { + parts.push(format!("stacker: {:?}", stacker)); + } + format!("{{ {} }}", parts.join(", ")) + } + }; + format!("{:?}: {value}", region) + }) + .collect::>() + .join(", "); format!("{{ {rendered} }}") } fn render_networking(networking: Option<&serde_json::Value>, lines: &mut Vec) { - let Some(networking) = networking.and_then(|value| value.as_object()) else { return; }; + let Some(networking) = networking.and_then(|value| value.as_object()) else { + return; + }; let mut remaining = networking.clone(); remaining.remove("serviceDomains"); if let Some(custom_domains) = remaining.remove("customDomains") { - if let Some(domains) = custom_domains.as_object().filter(|domains| !domains.is_empty()) { - let rendered = domains.iter().map(|(domain, config)| { - let port = config.get("port").and_then(|value| value.as_u64()); - match port { - Some(8080) | None => format!("{:?}", domain), - Some(port) => format!("{{ domain: {:?}, port: {port} }}", domain), - } - }).collect::>().join(", "); + if let Some(domains) = custom_domains + .as_object() + .filter(|domains| !domains.is_empty()) + { + let rendered = domains + .iter() + .map(|(domain, config)| { + let port = config.get("port").and_then(|value| value.as_u64()); + match port { + Some(8080) | None => format!("{:?}", domain), + Some(port) => format!("{{ domain: {:?}, port: {port} }}", domain), + } + }) + .collect::>() + .join(", "); lines.push(format!(" domains: [{rendered}],")); } } if !remaining.is_empty() { - lines.push(format!(" networking: {},", ts_value(&serde_json::Value::Object(remaining)))); + lines.push(format!( + " networking: {},", + ts_value(&serde_json::Value::Object(remaining)) + )); } } @@ -561,7 +807,10 @@ fn ts_value(value: &serde_json::Value) -> String { .join(", "); format!("{{ {fields} }}") } - serde_json::Value::Array(values) => format!("[{}]", values.iter().map(ts_value).collect::>().join(", ")), + serde_json::Value::Array(values) => format!( + "[{}]", + values.iter().map(ts_value).collect::>().join(", ") + ), serde_json::Value::String(value) => format!("{:?}", value), serde_json::Value::Number(value) => value.to_string(), serde_json::Value::Bool(value) => value.to_string(), @@ -570,8 +819,13 @@ fn ts_value(value: &serde_json::Value) -> String { } fn ts_key(key: &str) -> String { - if key.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') - && key.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + if key + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_') + && key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') { key.to_string() } else { @@ -579,7 +833,12 @@ fn ts_key(key: &str) -> String { } } -fn unique_resource_ident(name: &str, resource_type: &str, reserved: &std::collections::HashSet<&str>, used: &[String]) -> String { +fn unique_resource_ident( + name: &str, + resource_type: &str, + reserved: &std::collections::HashSet<&str>, + used: &[String], +) -> String { let mut candidate = sanitize_ident(name); if candidate.is_empty() || reserved.contains(candidate.as_str()) { candidate = match resource_type { @@ -588,7 +847,11 @@ fn unique_resource_ident(name: &str, resource_type: &str, reserved: &std::collec _ => format!("{}Resource", candidate), }; } - if candidate.is_empty() || candidate == "Database" || candidate == "Service" || candidate == "Resource" { + if candidate.is_empty() + || candidate == "Database" + || candidate == "Service" + || candidate == "Resource" + { candidate = "resource".to_string(); } let base = candidate.clone(); @@ -633,7 +896,9 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { .ok() .and_then(|contents| serde_json::from_str::(&contents).ok()) .unwrap_or_default(); - let scripts = package.get("scripts").and_then(|scripts| scripts.as_object()); + let scripts = package + .get("scripts") + .and_then(|scripts| scripts.as_object()); let package_manager = detect_package_manager(cwd); let build = script_command(scripts, "build").map(|_| format!("{package_manager} run build")); let start = script_command(scripts, "start") @@ -660,7 +925,9 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { if let Some(source) = github_source { out.push_str(&format!(" source: github({:?}),\n", source)); } else { - out.push_str(" // No GitHub remote detected. `railway up` will upload this directory.\n"); + out.push_str( + " // No GitHub remote detected. `railway up` will upload this directory.\n", + ); } if let Some(build) = build { out.push_str(&format!(" build: {:?},\n", build)); @@ -675,7 +942,10 @@ fn railway_ts_from_repo(cwd: &Path, project_name: &str) -> String { out } -fn script_command<'a>(scripts: Option<&'a serde_json::Map>, name: &str) -> Option<&'a str> { +fn script_command<'a>( + scripts: Option<&'a serde_json::Map>, + name: &str, +) -> Option<&'a str> { scripts .and_then(|scripts| scripts.get(name)) .and_then(|value| value.as_str()) @@ -698,7 +968,11 @@ fn parse_github_remote(remote: &str) -> Option { if let Some(path) = remote.strip_prefix("git@github.com:") { return Some(path.to_string()); } - for prefix in ["https://github.com/", "http://github.com/", "ssh://git@github.com/"] { + for prefix in [ + "https://github.com/", + "http://github.com/", + "ssh://git@github.com/", + ] { if let Some(path) = remote.strip_prefix(prefix) { return Some(path.to_string()); } @@ -770,7 +1044,11 @@ async fn ensure_config_initialized(args: &SharedArgs) -> Result<()> { println!(); println!("{}", "Railway configuration is not initialized yet.".bold()); - println!("{} {}", "Create".dimmed(), railway_file.display().to_string().cyan()); + println!( + "{} {}", + "Create".dimmed(), + railway_file.display().to_string().cyan() + ); println!(); let should_init = if args.yes { diff --git a/src/commands/link.rs b/src/commands/link.rs index 705006f3f..e0a58b175 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -7,7 +7,10 @@ use std::{collections::HashSet, fmt::Display}; use crate::{ controllers::project::{get_environment_instances, get_service_ids_in_env}, errors::RailwayError, - util::prompt::{fake_select, prompt_options, prompt_options_skippable, prompt_select, prompt_text_with_placeholder_if_blank}, + util::prompt::{ + fake_select, prompt_options, prompt_options_skippable, prompt_select, + prompt_text_with_placeholder_if_blank, + }, workspace::{Project, Workspace, workspaces}, }; @@ -125,13 +128,18 @@ pub async fn link_project_without_service() -> Result { let workspace = select_workspace(None, None, workspaces)?; let project_name = prompt_new_project_name()?; let vars = mutations::project_create::Variables { - name: if project_name.is_empty() { None } else { Some(project_name) }, + name: if project_name.is_empty() { + None + } else { + Some(project_name) + }, description: None, workspace_id: Some(workspace.id().to_owned()), }; - let project = post_graphql::(&client, configs.get_backboard(), vars) - .await? - .project_create; + let project = + post_graphql::(&client, configs.get_backboard(), vars) + .await? + .project_create; let environment = project .environments .edges @@ -241,20 +249,24 @@ async fn link_command(args: Args, require_service: bool) -> Result<()> { fn prompt_new_project_name() -> Result { let default_name = std::env::current_dir() .ok() - .and_then(|path| path.file_name().map(|name| name.to_string_lossy().to_string())) + .and_then(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) .unwrap_or_else(|| "railway-project".to_string()); if !std::io::stdout().is_terminal() { return Ok(default_name); } - let maybe_name = prompt_text_with_placeholder_if_blank( - "Project name", - &default_name, - &default_name, - )?; + let maybe_name = + prompt_text_with_placeholder_if_blank("Project name", &default_name, &default_name)?; let name = maybe_name.trim(); - Ok(if name.is_empty() { default_name } else { name.to_string() }) + Ok(if name.is_empty() { + default_name + } else { + name.to_string() + }) } fn select_service( diff --git a/src/commands/sync.rs b/src/commands/sync.rs index d4ca7798b..ec127c7d4 100644 --- a/src/commands/sync.rs +++ b/src/commands/sync.rs @@ -6,7 +6,10 @@ use serde::Deserialize; use serde_json::Value; use tokio::{io::AsyncWriteExt, process::Command}; -use crate::util::{progress::{create_spinner_if, fail_spinner, success_spinner}, prompt::prompt_confirm_with_default}; +use crate::util::{ + progress::{create_spinner_if, fail_spinner, success_spinner}, + prompt::prompt_confirm_with_default, +}; use super::*; @@ -157,11 +160,21 @@ pub(super) async fn run(args: &Args, command: &str) -> Result { pub async fn command(args: Args) -> Result<()> { let (configs, linked_project, token, auth_type) = ensure_config_context().await?; - let command = if args.stage { "stage" } else if args.apply || args.yes { "apply" } else { "plan" }; + let command = if args.stage { + "stage" + } else if args.apply || args.yes { + "apply" + } else { + "plan" + }; if args.stage && !args.yes { - let mut spinner = create_spinner_if(!args.json && std::io::stdout().is_terminal(), "Checking proposed changes".into()); - let preview = invoke_runner(&args, &configs, &linked_project, &token, auth_type, "plan").await?; + let mut spinner = create_spinner_if( + !args.json && std::io::stdout().is_terminal(), + "Checking proposed changes".into(), + ); + let preview = + invoke_runner(&args, &configs, &linked_project, &token, auth_type, "plan").await?; if let Some(spinner) = &mut spinner { if preview.ok { success_spinner(spinner, "Checked proposed changes".into()); @@ -171,7 +184,9 @@ pub async fn command(args: Args) -> Result<()> { } if has_destructive_changes(&preview) { - bail!("These changes remove Railway resources. Re-run with --stage --yes to stage them."); + bail!( + "These changes remove Railway resources. Re-run with --stage --yes to stage them." + ); } } @@ -181,7 +196,8 @@ pub async fn command(args: Args) -> Result<()> { } let mut spinner = create_spinner_if(true, "Checking Railway configuration".into()); - let preview = invoke_runner(&args, &configs, &linked_project, &token, auth_type, "plan").await?; + let preview = + invoke_runner(&args, &configs, &linked_project, &token, auth_type, "plan").await?; if let Some(spinner) = &mut spinner { if preview.ok { success_spinner(spinner, "Checked Railway configuration".into()); @@ -194,7 +210,11 @@ pub async fn command(args: Args) -> Result<()> { if !preview.ok { bail!("IaC runner returned diagnostics"); } - let changes = preview.change_set.as_ref().map(|change_set| change_set.changes.len()).unwrap_or(0); + let changes = preview + .change_set + .as_ref() + .map(|change_set| change_set.changes.len()) + .unwrap_or(0); if changes == 0 { return Ok(()); } @@ -212,8 +232,12 @@ pub async fn command(args: Args) -> Result<()> { println!(); } - let mut spinner = create_spinner_if(!args.json && std::io::stdout().is_terminal(), runner_message(command).into()); - let output = invoke_runner(&args, &configs, &linked_project, &token, auth_type, command).await?; + let mut spinner = create_spinner_if( + !args.json && std::io::stdout().is_terminal(), + runner_message(command).into(), + ); + let output = + invoke_runner(&args, &configs, &linked_project, &token, auth_type, command).await?; if let Some(spinner) = &mut spinner { if output.ok { success_spinner(spinner, runner_done_message(command).into()); @@ -272,7 +296,9 @@ fn get_runner_token(configs: &Configs) -> Result<(String, &'static str)> { configs .get_railway_auth_token() .map(|token| (token, "bearer")) - .context("Not authenticated. Run `railway login`, set RAILWAY_API_TOKEN, or set RAILWAY_TOKEN.") + .context( + "Not authenticated. Run `railway login`, set RAILWAY_API_TOKEN, or set RAILWAY_TOKEN.", + ) } async fn invoke_runner( @@ -401,10 +427,18 @@ pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bo print_response_with_options_and_next(response, verbose, true); } -pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, verbose: bool, show_next: bool) { +pub(super) fn print_response_with_options_and_next( + response: &RunnerResponse, + verbose: bool, + show_next: bool, +) { println!(); println!("{}", "Railway configuration".bold()); - println!("{} {}", "Using".dimmed(), display_file_path(&response.file).cyan()); + println!( + "{} {}", + "Using".dimmed(), + display_file_path(&response.file).cyan() + ); if let Some(environment) = &response.current_environment { let environment_name = environment @@ -451,10 +485,18 @@ pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, ve if verbose { println!(); println!("{} {}", "Result".dimmed(), apply_result.id.dimmed()); - if let Some(deployment_id) = response.deployment_id.as_ref().or(apply_result.deployment_id.as_ref()) { + if let Some(deployment_id) = response + .deployment_id + .as_ref() + .or(apply_result.deployment_id.as_ref()) + { println!("{} {}", "Deployment".dimmed(), deployment_id.dimmed()); } - if let Some(staged_patch_id) = response.staged_patch_id.as_ref().or(apply_result.staged_patch_id.as_ref()) { + if let Some(staged_patch_id) = response + .staged_patch_id + .as_ref() + .or(apply_result.staged_patch_id.as_ref()) + { println!("{} {}", "Patch".dimmed(), staged_patch_id.dimmed()); } } @@ -462,7 +504,10 @@ pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, ve } if changes.is_empty() { - println!("{}", "✓ Your Railway configuration is already up to date.".green()); + println!( + "{}", + "✓ Your Railway configuration is already up to date.".green() + ); } else { let total = changes.len(); println!("{} {}", "Changes".bold(), format!("({total})").dimmed()); @@ -486,7 +531,11 @@ pub(super) fn print_response_with_options_and_next(response: &RunnerResponse, ve if show_next { println!(); println!("{}", "Next".bold()); - println!(" {} Run {} to apply these changes.", "•".cyan(), "railway config apply".cyan()); + println!( + " {} Run {} to apply these changes.", + "•".cyan(), + "railway config apply".cyan() + ); } } } @@ -521,7 +570,12 @@ fn print_operation_results(apply_result: &ChangeSetApplyResult, verbose: bool) { _ => "•".cyan(), }; if verbose { - println!(" {} {} {}", marker, summary, format!("({})", change.status).dimmed()); + println!( + " {} {} {}", + marker, + summary, + format!("({})", change.status).dimmed() + ); } else { println!(" {} {}", marker, summary); } @@ -542,7 +596,12 @@ fn print_operation_outputs(value: &Value, indent: usize) { println!("{}{}", " ".repeat(indent), key.dimmed()); print_operation_outputs(value, indent + 2); } - _ => println!("{}{} {}", " ".repeat(indent), key.dimmed(), format_output_value(value).cyan()), + _ => println!( + "{}{} {}", + " ".repeat(indent), + key.dimmed(), + format_output_value(value).cyan() + ), } } } @@ -551,7 +610,11 @@ fn print_operation_outputs(value: &Value, indent: usize) { print_operation_outputs(value, indent); } } - _ => println!("{}{}", " ".repeat(indent), format_output_value(value).cyan()), + _ => println!( + "{}{}", + " ".repeat(indent), + format_output_value(value).cyan() + ), } } @@ -580,7 +643,9 @@ fn print_change(change: &Change, _verbose: bool) { fn marker_for_change(change: &Change) -> colored::ColoredString { match change.kind.as_deref() { - Some("resource.create") | Some("variable.set") | Some("domain.create") => "+".green().bold(), + Some("resource.create") | Some("variable.set") | Some("domain.create") => { + "+".green().bold() + } Some("resource.delete") | Some("variable.delete") => "-".red().bold(), _ => "~".yellow().bold(), } diff --git a/src/commands/up.rs b/src/commands/up.rs index a24d26cb6..4209f218d 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -134,7 +134,12 @@ pub async fn command(args: Args) -> Result<()> { })?; let environment_id = get_matched_environment(&project, environment)?.id; - let service = get_or_prompt_service(linked_project, project, args.service.clone().or(iac_service)).await?; + let service = get_or_prompt_service( + linked_project, + project, + args.service.clone().or(iac_service), + ) + .await?; let is_tty = std::io::stdout().is_terminal() && !args.json; @@ -409,7 +414,11 @@ async fn maybe_sync_iac_before_up(args: &Args) -> Result> { bail!("IaC runner returned diagnostics"); } - let changes = plan.change_set.as_ref().map(|change_set| change_set.changes.len()).unwrap_or(0); + let changes = plan + .change_set + .as_ref() + .map(|change_set| change_set.changes.len()) + .unwrap_or(0); if changes == 0 { crate::commands::sync::print_response(&plan); return Ok(infer_iac_deploy_service(&plan)); @@ -418,7 +427,9 @@ async fn maybe_sync_iac_before_up(args: &Args) -> Result> { if !args.yes { if !std::io::stdout().is_terminal() { if args.sync { - bail!("Applying Railway configuration before deploy requires --yes in non-interactive mode."); + bail!( + "Applying Railway configuration before deploy requires --yes in non-interactive mode." + ); } println!( "Found Railway configuration at {}, skipping project changes in non-interactive mode. Use --sync --yes to apply before deploy.", From faaaac6affbb621e6df446dd65c2bead6e022772 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 10:53:25 +0200 Subject: [PATCH 35/40] Keep Railway config scoped to config commands --- src/commands/config_command.rs | 16 +-- src/commands/{sync.rs => iac_runner.rs} | 6 +- src/commands/mod.rs | 2 +- src/commands/up.rs | 145 +----------------------- src/main.rs | 2 - 5 files changed, 14 insertions(+), 157 deletions(-) rename src/commands/{sync.rs => iac_runner.rs} (99%) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index 0446ce4ec..f0ff5e452 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -322,14 +322,16 @@ async fn write_pulled_config(path: &Path, force: bool, runner: Option) - write_new(path, &render_graph_as_railway_ts(&graph), force) } -async fn load_current_graph(runner: Option) -> Result { +async fn load_current_graph( + runner: Option, +) -> Result { let temp_dir = std::env::temp_dir().join(format!("railway-config-pull-{}", std::process::id())); fs::create_dir_all(&temp_dir).context("Failed to create temporary Railway config directory")?; let temp_file = temp_dir.join("railway.ts"); fs::write(&temp_file, railway_ts("import-placeholder")) .context("Failed to write temporary Railway config")?; - let args = crate::commands::sync::Args { + let args = crate::commands::iac_runner::Args { file: Some(temp_file.clone()), stage: false, json: true, @@ -340,7 +342,7 @@ async fn load_current_graph(runner: Option) -> Result) -> Result String { +fn render_graph_as_railway_ts(graph: &crate::commands::iac_runner::DesiredGraph) -> String { let mut imports = vec!["defineRailway", "project", "service"]; if graph .resources @@ -491,7 +493,7 @@ fn render_graph_as_railway_ts(graph: &crate::commands::sync::DesiredGraph) -> St } fn shared_github_sources( - graph: &crate::commands::sync::DesiredGraph, + graph: &crate::commands::iac_runner::DesiredGraph, ) -> std::collections::BTreeMap { let mut counts = std::collections::BTreeMap::::new(); for resource in &graph.resources { @@ -530,7 +532,7 @@ fn shared_github_sources( } fn render_service_body( - resource: &crate::commands::sync::DesiredResource, + resource: &crate::commands::iac_runner::DesiredResource, source_aliases: &std::collections::BTreeMap, ) -> String { let mut lines = Vec::new(); @@ -1017,7 +1019,7 @@ export default defineRailway(() => {{ async fn run_sync(args: SharedArgs, stage: bool, apply: bool) -> Result<()> { ensure_config_initialized(&args).await?; - crate::commands::sync::command(crate::commands::sync::Args { + crate::commands::iac_runner::run_command(crate::commands::iac_runner::Args { file: args.file, stage, json: args.json, diff --git a/src/commands/sync.rs b/src/commands/iac_runner.rs similarity index 99% rename from src/commands/sync.rs rename to src/commands/iac_runner.rs index ec127c7d4..a3b5676d7 100644 --- a/src/commands/sync.rs +++ b/src/commands/iac_runner.rs @@ -158,7 +158,7 @@ pub(super) async fn run(args: &Args, command: &str) -> Result { invoke_runner(args, &configs, &linked_project, &token, auth_type, command).await } -pub async fn command(args: Args) -> Result<()> { +pub(super) async fn run_command(args: Args) -> Result<()> { let (configs, linked_project, token, auth_type) = ensure_config_context().await?; let command = if args.stage { "stage" @@ -419,10 +419,6 @@ fn runner_done_message(command: &str) -> &'static str { } } -pub(super) fn print_response(response: &RunnerResponse) { - print_response_with_options(response, false); -} - pub(super) fn print_response_with_options(response: &RunnerResponse, verbose: bool) { print_response_with_options_and_next(response, verbose, true); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 6853b8aa3..7d85fa648 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -23,6 +23,7 @@ pub mod domain; pub mod down; pub mod environment; pub mod functions; +pub mod iac_runner; pub mod init; pub mod link; pub mod list; @@ -46,7 +47,6 @@ pub mod skills; pub mod ssh; pub mod starship; pub mod status; -pub mod sync; pub mod telemetry_cmd; pub mod templates; pub mod unlink; diff --git a/src/commands/up.rs b/src/commands/up.rs index 4209f218d..26d58f824 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -1,7 +1,4 @@ -use std::{ - path::{Path, PathBuf}, - time::Duration, -}; +use std::{path::PathBuf, time::Duration}; use anyhow::{Result, bail}; @@ -20,10 +17,7 @@ use crate::{ }, subscription::subscribe_graphql, subscriptions::deployment::DeploymentStatus, - util::{ - logs::{LogFormat, print_log}, - prompt::prompt_confirm_with_default, - }, + util::logs::{LogFormat, print_log}, }; use super::*; @@ -72,18 +66,6 @@ pub struct Args { /// Output logs in JSON format (implies CI mode behavior) json: bool, - #[clap(long)] - /// Apply Railway configuration before deploying if .railway/railway.ts exists - sync: bool, - - #[clap(long)] - /// Do not apply Railway configuration before deploying - no_sync: bool, - - #[clap(long)] - /// Confirm Railway configuration prompts - yes: bool, - #[clap(short, long)] /// Message to attach to the deployment message: Option, @@ -98,8 +80,6 @@ pub async fn command(args: Args) -> Result<()> { bail!("--environment is required when using --project"); } - let iac_service = maybe_sync_iac_before_up(&args).await?; - let linked_project = if args.project.is_none() { Some(configs.get_linked_project().await?) } else { @@ -134,12 +114,7 @@ pub async fn command(args: Args) -> Result<()> { })?; let environment_id = get_matched_environment(&project, environment)?.id; - let service = get_or_prompt_service( - linked_project, - project, - args.service.clone().or(iac_service), - ) - .await?; + let service = get_or_prompt_service(linked_project, project, args.service).await?; let is_tty = std::io::stdout().is_terminal() && !args.json; @@ -386,120 +361,6 @@ pub async fn command(args: Args) -> Result<()> { Ok(()) } -async fn maybe_sync_iac_before_up(args: &Args) -> Result> { - if args.no_sync { - return Ok(None); - } - - let railway_file = match find_railway_file(std::env::current_dir()?) { - Some(file) => file, - None => return Ok(None), - }; - - let sync_args = crate::commands::sync::Args { - file: Some(railway_file.clone()), - stage: false, - json: args.json, - yes: true, - apply: false, - decrypt_variables: false, - include_types: false, - runner: None, - verbose: false, - }; - - let plan = crate::commands::sync::run(&sync_args, "plan").await?; - if !plan.ok { - crate::commands::sync::print_response(&plan); - bail!("IaC runner returned diagnostics"); - } - - let changes = plan - .change_set - .as_ref() - .map(|change_set| change_set.changes.len()) - .unwrap_or(0); - if changes == 0 { - crate::commands::sync::print_response(&plan); - return Ok(infer_iac_deploy_service(&plan)); - } - - if !args.yes { - if !std::io::stdout().is_terminal() { - if args.sync { - bail!( - "Applying Railway configuration before deploy requires --yes in non-interactive mode." - ); - } - println!( - "Found Railway configuration at {}, skipping project changes in non-interactive mode. Use --sync --yes to apply before deploy.", - display_path(&railway_file) - ); - return Ok(None); - } - - crate::commands::sync::print_response_with_options_and_next(&plan, true, false); - println!(); - let apply_sync = prompt_confirm_with_default( - "Apply these Railway configuration changes before deploying?", - true, - )?; - if !apply_sync { - return Ok(infer_iac_deploy_service(&plan)); - } - println!(); - } - - let apply_args = crate::commands::sync::Args { - apply: true, - ..sync_args - }; - let response = crate::commands::sync::run(&apply_args, "apply").await?; - crate::commands::sync::print_response(&response); - if !response.ok { - bail!("IaC runner returned diagnostics"); - } - Ok(infer_iac_deploy_service(&response)) -} - -fn display_path(path: &Path) -> String { - let cwd = std::env::current_dir().ok(); - let display_path = cwd - .as_ref() - .and_then(|cwd| path.strip_prefix(cwd).ok()) - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or(path); - display_path.display().to_string() -} - -fn infer_iac_deploy_service(response: &crate::commands::sync::RunnerResponse) -> Option { - let services = response - .desired_graph - .as_ref()? - .resources - .iter() - .filter(|resource| resource.r#type == "service") - .collect::>(); - if services.len() == 1 { - Some(services[0].name.clone()) - } else { - None - } -} - -fn find_railway_file(start: PathBuf) -> Option { - let mut cursor = start; - loop { - let file = cursor.join(".railway/railway.ts"); - if file.exists() { - return Some(file); - } - if !cursor.pop() { - return None; - } - } -} - struct DeployPaths { project_path: PathBuf, archive_prefix_path: PathBuf, diff --git a/src/main.rs b/src/main.rs index 00620b871..50571b16f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,7 +64,6 @@ commands!( ssh, starship, status, - sync, telemetry_cmd(telemetry), templates, unlink, @@ -458,7 +457,6 @@ mod cli_tests { assert_subcommand(&["link"], "link"); assert_subcommand(&["up"], "up"); assert_subcommand(&["redeploy"], "redeploy"); - assert_subcommand(&["sync"], "sync"); } #[test] From f414dc2d95c92da69967d5e967fab7d8a6a7dc2f Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 11:12:02 +0200 Subject: [PATCH 36/40] Resolve Railway config runner from project --- src/commands/iac_runner.rs | 85 ++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/src/commands/iac_runner.rs b/src/commands/iac_runner.rs index a3b5676d7..287f1e202 100644 --- a/src/commands/iac_runner.rs +++ b/src/commands/iac_runner.rs @@ -309,16 +309,10 @@ async fn invoke_runner( auth_type: &str, command: &str, ) -> Result { - let runner = args - .runner - .clone() - .or_else(|| env::var("RAILWAY_IAC_TS_BIN").ok()) - .unwrap_or_else(|| "railway-iac-ts".to_string()); + let cwd_path = env::current_dir().context("Unable to get current working directory")?; + let runner = resolve_runner(args.runner.as_deref(), &cwd_path); - let cwd = env::current_dir() - .context("Unable to get current working directory")? - .to_string_lossy() - .to_string(); + let cwd = cwd_path.to_string_lossy().to_string(); let request = serde_json::json!({ "command": command, @@ -344,8 +338,8 @@ async fn invoke_runner( } }); - let mut command = Command::new(&runner); - if let Some(runner_cwd) = runner_cwd(&runner) { + let mut command = Command::new(&runner.path); + if let Some(runner_cwd) = runner_cwd(&runner.path) { command.current_dir(runner_cwd); } if matches!(Configs::get_environment_id(), Environment::Dev) { @@ -357,7 +351,7 @@ async fn invoke_runner( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .with_context(|| format!("Failed to spawn IaC runner `{runner}`. Install/link the railway TypeScript SDK or pass --runner."))?; + .with_context(|| runner_not_found_message(&runner))?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(request.to_string().as_bytes()).await?; @@ -374,6 +368,73 @@ async fn invoke_runner( Ok(response) } +struct ResolvedRunner { + path: String, + source: RunnerSource, +} + +enum RunnerSource { + Explicit, + Env, + ProjectDependency, + Path, +} + +fn resolve_runner(explicit_runner: Option<&str>, cwd: &std::path::Path) -> ResolvedRunner { + if let Some(runner) = explicit_runner { + return ResolvedRunner { + path: runner.to_string(), + source: RunnerSource::Explicit, + }; + } + + if let Ok(runner) = env::var("RAILWAY_IAC_TS_BIN") { + return ResolvedRunner { + path: runner, + source: RunnerSource::Env, + }; + } + + if let Some(runner) = find_project_runner(cwd) { + return ResolvedRunner { + path: runner.to_string_lossy().to_string(), + source: RunnerSource::ProjectDependency, + }; + } + + ResolvedRunner { + path: "railway-iac-ts".to_string(), + source: RunnerSource::Path, + } +} + +fn find_project_runner(start: &std::path::Path) -> Option { + let binary = if cfg!(windows) { + "railway-iac-ts.cmd" + } else { + "railway-iac-ts" + }; + + for dir in start.ancestors() { + let candidate = dir.join("node_modules").join(".bin").join(binary); + if candidate.exists() { + return Some(candidate); + } + } + + None +} + +fn runner_not_found_message(runner: &ResolvedRunner) -> String { + match runner.source { + RunnerSource::Explicit | RunnerSource::Env => format!( + "Could not start Railway configuration support from `{}`. Check that the path exists and is executable.", + runner.path + ), + RunnerSource::ProjectDependency | RunnerSource::Path => "Could not find Railway configuration support for this project. Install the Railway TypeScript package with your package manager, then run this command again. For example: `npm install railway`.".to_string(), + } +} + fn runner_cwd(runner: &str) -> Option { let path = PathBuf::from(runner); if path.file_name()?.to_str()? != "bin.js" { From b5c25724ce3723f229158ee568f431b766b5006e Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 11:15:48 +0200 Subject: [PATCH 37/40] Tighten config runner install error --- src/commands/iac_runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/iac_runner.rs b/src/commands/iac_runner.rs index 287f1e202..f8952b049 100644 --- a/src/commands/iac_runner.rs +++ b/src/commands/iac_runner.rs @@ -431,7 +431,7 @@ fn runner_not_found_message(runner: &ResolvedRunner) -> String { "Could not start Railway configuration support from `{}`. Check that the path exists and is executable.", runner.path ), - RunnerSource::ProjectDependency | RunnerSource::Path => "Could not find Railway configuration support for this project. Install the Railway TypeScript package with your package manager, then run this command again. For example: `npm install railway`.".to_string(), + RunnerSource::ProjectDependency | RunnerSource::Path => "Could not find Railway configuration support for this project. Install the Railway TypeScript SDK, then run this command again: https://github.com/railwayapp/railway-ts-sdk".to_string(), } } From e245f2303562b4cb42b8c21defd36983fc078768 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 11:17:34 +0200 Subject: [PATCH 38/40] Link IaC docs from config init --- src/commands/config_command.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commands/config_command.rs b/src/commands/config_command.rs index f0ff5e452..5b66841a3 100644 --- a/src/commands/config_command.rs +++ b/src/commands/config_command.rs @@ -148,6 +148,11 @@ async fn init_config(args: InitArgs) -> Result<()> { println!("{}", "Initialize Railway configuration".bold()); println!("Railway will create the files that define your project infrastructure as code."); println!("{} {}", "Main file".dimmed(), ".railway/railway.ts".cyan()); + println!( + "{} {}", + "Docs".dimmed(), + "https://docs.railway.com/infrastructure-as-code".cyan() + ); println!(); prompt_select( "How should Railway start?", @@ -217,6 +222,11 @@ async fn init_config(args: InitArgs) -> Result<()> { "•".cyan(), "railway config apply".cyan() ); + println!( + " {} Read the guide and reference at {}.", + "•".cyan(), + "https://docs.railway.com/infrastructure-as-code".cyan() + ); Ok(()) } From fd98f6bb9c1bd9a5010984ea78720abac3ad15d1 Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 11:40:32 +0200 Subject: [PATCH 39/40] Use standard config command module --- src/commands/{config_command.rs => config.rs} | 0 src/commands/mod.rs | 2 +- src/macros.rs | 12 ++++++------ src/main.rs | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename src/commands/{config_command.rs => config.rs} (100%) diff --git a/src/commands/config_command.rs b/src/commands/config.rs similarity index 100% rename from src/commands/config_command.rs rename to src/commands/config.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 7d85fa648..3e48e029c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,7 +12,7 @@ pub mod add; pub mod autoupdate; pub mod bucket; pub mod completion; -pub mod config_command; +pub mod config; pub mod connect; pub mod delete; pub mod deploy; diff --git a/src/macros.rs b/src/macros.rs index 9e9af6964..b7aed8d2c 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -18,7 +18,7 @@ macro_rules! commands { $( { // Get the subcommand as defined by the module. - let sub = <$module::Args as ::clap::CommandFactory>::command(); + let sub = <$crate::commands::$module::Args as ::clap::CommandFactory>::command(); // Allow the module to add dynamic arguments (if needed) and add any aliases. let sub = { let mut s = sub; @@ -40,7 +40,7 @@ macro_rules! commands { s = s.name(stringify!($module)); s }; - let command_name = stringify!($module).strip_suffix("_command").unwrap_or(stringify!($module)); + let command_name = stringify!($module); // Add this subcommand into the global CLI. cmd = cmd.subcommand(sub.name(command_name)); } @@ -63,7 +63,7 @@ macro_rules! commands { pub async fn exec_cli(matches: clap::ArgMatches) -> anyhow::Result<()> { match matches.subcommand() { $( - Some((name, sub_matches)) if name == stringify!([<$module:snake>]).strip_suffix("_command").unwrap_or(stringify!([<$module:snake>])) => { + Some((stringify!([<$module:snake>]), sub_matches)) => { // Walk nested subcommand levels so telemetry can // distinguish e.g. `sandbox template build` from // `sandbox template status` ("template:build"). @@ -76,11 +76,11 @@ macro_rules! commands { } if parts.is_empty() { None } else { Some(parts.join(":")) } }; - let command_name = stringify!([<$module:snake>]).strip_suffix("_command").unwrap_or(stringify!([<$module:snake>])); - let args = <$module::Args as ::clap::FromArgMatches>::from_arg_matches(sub_matches) + let command_name = stringify!([<$module:snake>]); + let args = <$crate::commands::$module::Args as ::clap::FromArgMatches>::from_arg_matches(sub_matches) .map_err(anyhow::Error::from)?; let start = ::std::time::Instant::now(); - let result = $module::command(args).await; + let result = $crate::commands::$module::command(args).await; let duration = start.elapsed(); $crate::telemetry::send($crate::telemetry::CliTrackEvent { command: command_name.to_string(), diff --git a/src/main.rs b/src/main.rs index 50571b16f..15d61c31b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,7 +35,7 @@ commands!( autoupdate, bucket, completion, - config_command, + config, connect, delete, deploy, From 2a6f14148aa3d1b38eba71605e9c959a40483a9b Mon Sep 17 00:00:00 2001 From: Victor Ramirez Date: Fri, 5 Jun 2026 11:44:36 +0200 Subject: [PATCH 40/40] Nest Railway config runner module --- src/commands/{config.rs => config/mod.rs} | 26 +++++++++---------- .../{iac_runner.rs => config/runner.rs} | 0 src/commands/mod.rs | 1 - 3 files changed, 13 insertions(+), 14 deletions(-) rename src/commands/{config.rs => config/mod.rs} (97%) rename src/commands/{iac_runner.rs => config/runner.rs} (100%) diff --git a/src/commands/config.rs b/src/commands/config/mod.rs similarity index 97% rename from src/commands/config.rs rename to src/commands/config/mod.rs index 5b66841a3..d7497d9a7 100644 --- a/src/commands/config.rs +++ b/src/commands/config/mod.rs @@ -1,3 +1,5 @@ +mod runner; + use std::{ fs, path::{Path, PathBuf}, @@ -175,12 +177,12 @@ async fn init_config(args: InitArgs) -> Result<()> { } write_new( &readme_file, - include_str!("../../assets/railway-config/README.md"), + include_str!("../../../assets/railway-config/README.md"), args.force, )?; let wrote_skill = write_asset_if_missing( &skill_file, - include_str!("../../assets/railway-config/SKILL.md"), + include_str!("../../../assets/railway-config/SKILL.md"), )?; println!("{}", "Railway configuration initialized".green().bold()); @@ -278,11 +280,11 @@ async fn pull_config(args: PullArgs) -> Result<()> { write_pulled_config(&railway_file, args.force, args.runner).await?; let wrote_readme = write_asset_if_missing( &readme_file, - include_str!("../../assets/railway-config/README.md"), + include_str!("../../../assets/railway-config/README.md"), )?; let wrote_skill = write_asset_if_missing( &skill_file, - include_str!("../../assets/railway-config/SKILL.md"), + include_str!("../../../assets/railway-config/SKILL.md"), )?; println!("{}", "Railway configuration imported".green().bold()); @@ -332,16 +334,14 @@ async fn write_pulled_config(path: &Path, force: bool, runner: Option) - write_new(path, &render_graph_as_railway_ts(&graph), force) } -async fn load_current_graph( - runner: Option, -) -> Result { +async fn load_current_graph(runner: Option) -> Result { let temp_dir = std::env::temp_dir().join(format!("railway-config-pull-{}", std::process::id())); fs::create_dir_all(&temp_dir).context("Failed to create temporary Railway config directory")?; let temp_file = temp_dir.join("railway.ts"); fs::write(&temp_file, railway_ts("import-placeholder")) .context("Failed to write temporary Railway config")?; - let args = crate::commands::iac_runner::Args { + let args = runner::Args { file: Some(temp_file.clone()), stage: false, json: true, @@ -352,7 +352,7 @@ async fn load_current_graph( runner, verbose: false, }; - let response = crate::commands::iac_runner::run(&args, "current").await?; + let response = runner::run(&args, "current").await?; let _ = fs::remove_file(temp_file); let _ = fs::remove_dir(temp_dir); @@ -365,7 +365,7 @@ async fn load_current_graph( .context("Railway did not return current project state") } -fn render_graph_as_railway_ts(graph: &crate::commands::iac_runner::DesiredGraph) -> String { +fn render_graph_as_railway_ts(graph: &runner::DesiredGraph) -> String { let mut imports = vec!["defineRailway", "project", "service"]; if graph .resources @@ -503,7 +503,7 @@ fn render_graph_as_railway_ts(graph: &crate::commands::iac_runner::DesiredGraph) } fn shared_github_sources( - graph: &crate::commands::iac_runner::DesiredGraph, + graph: &runner::DesiredGraph, ) -> std::collections::BTreeMap { let mut counts = std::collections::BTreeMap::::new(); for resource in &graph.resources { @@ -542,7 +542,7 @@ fn shared_github_sources( } fn render_service_body( - resource: &crate::commands::iac_runner::DesiredResource, + resource: &runner::DesiredResource, source_aliases: &std::collections::BTreeMap, ) -> String { let mut lines = Vec::new(); @@ -1029,7 +1029,7 @@ export default defineRailway(() => {{ async fn run_sync(args: SharedArgs, stage: bool, apply: bool) -> Result<()> { ensure_config_initialized(&args).await?; - crate::commands::iac_runner::run_command(crate::commands::iac_runner::Args { + runner::run_command(runner::Args { file: args.file, stage, json: args.json, diff --git a/src/commands/iac_runner.rs b/src/commands/config/runner.rs similarity index 100% rename from src/commands/iac_runner.rs rename to src/commands/config/runner.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 3e48e029c..dd44ef6c5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -23,7 +23,6 @@ pub mod domain; pub mod down; pub mod environment; pub mod functions; -pub mod iac_runner; pub mod init; pub mod link; pub mod list;