diff --git a/assets/railway-config/README.md b/assets/railway-config/README.md new file mode 100644 index 000000000..1a67797d8 --- /dev/null +++ b/assets/railway-config/README.md @@ -0,0 +1,53 @@ +# Railway configuration + +This project defines its Railway infrastructure in code. + +```txt +.railway/railway.ts +``` + +Use this file to describe the Railway project you want: services, databases, buckets, custom domains, replicas, groups, and environment variables. + +## Common commands + +Create the configuration files: + +```bash +railway config init +``` + +Import an existing Railway project into code: + +```bash +railway config pull +``` + +Preview what Railway would change: + +```bash +railway config plan +``` + +Apply the planned changes: + +```bash +railway config apply +``` + +Deploy this directory: + +```bash +railway up +``` + +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. +- 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 new file mode 100644 index 000000000..3c8f69c87 --- /dev/null +++ b/assets/railway-config/SKILL.md @@ -0,0 +1,212 @@ +--- +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, replicas/regions, groups, environment variables, `railway config *`, `.railway/railway.ts`, or `railway up` behavior. +--- + +# 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 +``` + +## 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`, Backboard internals, or generated Railway domains into source. +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. 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. 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 + +Initialize configuration files: + +```bash +railway config init +``` + +Import current Railway state: + +```bash +railway config pull +``` + +Preview changes: + +```bash +railway config plan +``` + +Apply changes: + +```bash +railway config apply +``` + +Deploy this directory: + +```bash +railway up +``` + +Machine-readable preview: + +```bash +railway config plan --json +``` + +## Authoring + +Use Railway configuration helpers: + +```ts +import { + bucket, + defineRailway, + github, + group, + image, + mongo, + mysql, + postgres, + preserve, + project, + redis, + service, +} from "railway/iac"; +``` + +Minimal local service: + +```ts +const web = service("web", { + 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", +}); +``` + +Docker image service: + +```ts +const worker = service("worker", { + source: image("ghcr.io/acme/worker:latest"), +}); +``` + +Database reference: + +```ts +const db = postgres("postgres"); + +const web = service("web", { + env: { + DATABASE_URL: db.env.DATABASE_URL, + }, +}); +``` + +Service-to-service reference: + +```ts +const api = service("api", { + env: { + INTERNAL_TOKEN: preserve(), + }, +}); + +const web = service("web", { + env: { + API_TOKEN: api.env.INTERNAL_TOKEN, + API_HOST: api.env.RAILWAY_PRIVATE_DOMAIN, + }, +}); +``` + +Custom domains: + +```ts +const web = service("web", { + domains: ["app.example.com"], +}); +``` + +Replicas: + +```ts +const web = service("web", { + 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 +const media = bucket("media", { region: "iad" }); +``` + +Project shape: + +```ts +export default defineRailway(() => { + const db = postgres("postgres"); + const web = service("web", { + env: { + DATABASE_URL: db.env.DATABASE_URL, + }, + }); + + return project("my-app", { + resources: [db, web], + }); +}); +``` + +## 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 are omitted or use `preserve()` when the value should remain untouched. +- Custom domains are declared with `domains`, not networking internals. +- Scaling is declared with `replicas`, not `multiRegionConfig`. +- No generated Railway service domains are committed. diff --git a/src/commands/config/mod.rs b/src/commands/config/mod.rs new file mode 100644 index 000000000..d7497d9a7 --- /dev/null +++ b/src/commands/config/mod.rs @@ -0,0 +1,1082 @@ +mod runner; + +use std::{ + fs, + path::{Path, PathBuf}, + process::Command as ProcessCommand, +}; + +use is_terminal::IsTerminal; + +use crate::util::prompt::{prompt_confirm_with_default, prompt_select}; + +use super::*; + +/// Define, import, preview, and apply your Railway project from .railway/railway.ts +#[derive(Parser)] +pub struct Args { + #[clap(subcommand)] + command: Command, +} + +#[derive(Parser)] +enum Command { + /// Preview the changes Railway would make from .railway/railway.ts without applying them + Plan(SharedArgs), + + /// Staged Railway configuration changes are not available yet; use `railway config plan` or `railway config apply` + #[clap(hide = true)] + Stage(SharedArgs), + + /// Apply the changes from .railway/railway.ts to the linked Railway project + Apply(SharedArgs), + + /// Create .railway/railway.ts for this repo or import from the linked project + Init(InitArgs), + + /// Import the linked Railway project's current configuration into .railway/railway.ts + 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, "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"), + } + } +} + +#[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) => 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, + } +} + +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(".agents").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 { + 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!( + "{} {}", + "Docs".dimmed(), + "https://docs.railway.com/infrastructure-as-code".cyan() + ); + println!(); + prompt_select( + "How should Railway start?", + 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() + ); + println!( + " {} Read the guide and reference at {}.", + "•".cyan(), + "https://docs.railway.com/infrastructure-as-code".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"); + 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?; + println!("{}", serde_json::to_string_pretty(&graph)?); + return Ok(()); + } + + 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() + ); + 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 = runner::Args { + file: Some(temp_file.clone()), + stage: false, + json: true, + yes: false, + apply: false, + decrypt_variables: false, + include_types: false, + runner, + verbose: false, + }; + let response = runner::run(&args, "current").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: &runner::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"); + } + // 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"); + } + imports.sort(); + imports.dedup(); + + 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 { + 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, &source_aliases); + 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); + } + _ => {} + } + } + + 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"); + out.push_str("});\n"); + out +} + +fn shared_github_sources( + graph: &runner::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: &runner::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"); + 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}),")); + } + } 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); + 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() { + 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.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)); + return; + } + } + } + if !is_empty_object(build) { + 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!(" replicas: {},", render_replicas(®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_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)| { + 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 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(", "); + lines.push(format!(" domains: [{rendered}],")); + } + } + + if !remaining.is_empty() { + lines.push(format!( + " networking: {},", + ts_value(&serde_json::Value::Object(remaining)) + )); + } +} + +fn is_empty_object(value: &serde_json::Value) -> 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) => { + 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 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 { + "defineRailway, project, service" + }; + 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)); + } + if let Some(start) = start { + out.push_str(&format!(" start: {:?},\n", start)); + } + + out.push_str(" });\n\n"); + out.push_str(&format!(" return project(\"{project_name}\", {{\n")); + out.push_str(" resources: [web],\n });\n});\n"); + 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() + } 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}", {{ + resources: [web], + }}); +}}); +"# + ) +} + +async fn run_sync(args: SharedArgs, stage: bool, apply: bool) -> Result<()> { + ensure_config_initialized(&args).await?; + + runner::run_command(runner::Args { + file: args.file, + stage, + json: args.json, + yes: args.yes, + apply, + decrypt_variables: args.decrypt_variables, + include_types: args.include_types, + runner: args.runner, + verbose: args.verbose, + }) + .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(()) +} diff --git a/src/commands/config/runner.rs b/src/commands/config/runner.rs new file mode 100644 index 000000000..f8952b049 --- /dev/null +++ b/src/commands/config/runner.rs @@ -0,0 +1,709 @@ +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}, + prompt::prompt_confirm_with_default, +}; + +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)] + pub(super) file: Option, + + /// Stage the proposed ChangeSet in Backboard. + #[clap(long)] + pub(super) stage: bool, + + /// Output raw runner JSON. + #[clap(long)] + pub(super) json: bool, + + /// 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, + + /// Include generated graph TypeScript types in runner output. + #[clap(long)] + pub(super) include_types: bool, + + /// Path to the TypeScript IaC runner binary. Defaults to RAILWAY_IAC_TS_BIN or railway-iac-ts. + #[clap(long)] + pub(super) runner: Option, + + /// Show full change details. + #[clap(long, alias = "full")] + pub(super) verbose: bool, +} + +#[derive(Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct RunnerResponse { + pub(super) ok: bool, + command: String, + file: String, + current_environment: Option, + pub(super) 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)] +#[serde(rename_all = "camelCase")] +struct CurrentEnvironment { + project_id: Option, + environment_id: String, + environment_name: Option, +} + +#[derive(Deserialize, serde::Serialize)] +pub(super) struct ChangeSet { + pub(super) changes: Vec, +} + +#[derive(Deserialize, serde::Serialize)] +pub(super) struct Change { + summary: Option, + severity: Option, + kind: Option, + details: Option>, +} + +#[derive(Deserialize, serde::Serialize)] +struct Diagnostic { + severity: String, + path: String, + 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) 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 { + 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, + #[allow(dead_code)] + patch: Option, +} + +pub(super) async fn run(args: &Args, command: &str) -> Result { + let (configs, linked_project, token, auth_type) = ensure_config_context().await?; + invoke_runner(args, &configs, &linked_project, &token, auth_type, command).await +} + +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" + } 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?; + 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!( + "These changes remove Railway resources. Re-run with --stage --yes to stage them." + ); + } + } + + 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_and_next(&preview, args.verbose, false); + 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 { + 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)?); + if !output.ok { + bail!("IaC runner returned diagnostics"); + } + return Ok(()); + } + + print_response_with_options(&output, args.verbose); + if !output.ok { + bail!("IaC runner returned diagnostics"); + } + + 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!(); + 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), + }; + + 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")); + } + + configs + .get_railway_auth_token() + .map(|token| (token, "bearer")) + .context( + "Not authenticated. Run `railway login`, set RAILWAY_API_TOKEN, or set RAILWAY_TOKEN.", + ) +} + +async fn invoke_runner( + args: &Args, + configs: &Configs, + linked_project: &LinkedProject, + token: &str, + auth_type: &str, + command: &str, +) -> Result { + 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 = cwd_path.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, + "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, + "authType": auth_type, + "projectId": linked_project.project, + "environmentId": linked_project.environment, + "decryptVariables": args.decrypt_variables, + "merge": true + } + }); + + 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) { + command.env("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + } + + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| runner_not_found_message(&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) +} + +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 SDK, then run this command again: https://github.com/railwayapp/railway-ts-sdk".to_string(), + } +} + +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 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_with_options(response: &RunnerResponse, verbose: bool) { + 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, +) { + println!(); + println!("{}", "Railway configuration".bold()); + println!( + "{} {}", + "Using".dimmed(), + display_file_path(&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 verbose { + if let Some(project_id) = &environment.project_id { + println!("{} {}", "Project".dimmed(), project_id.dimmed()); + } + } + } + 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!("{} {}", "Error".red().bold(), text.red()); + } else { + println!("{} {}", "Warning".yellow().bold(), text.yellow()); + } + } + + if !response.ok { + return; + } + + let changes = response + .change_set + .as_ref() + .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(); + println!("{} {}", "Changes".bold(), format!("({total})").dimmed()); + for change in changes { + print_change(change, verbose); + } + + let destructive = changes + .iter() + .filter(|change| change.severity.as_deref() == Some("destructive")) + .count(); + if destructive > 0 { + println!(); + println!( + "{} {}", + "!".red().bold(), + format!("{destructive} destructive change(s) will remove Railway resources or variables.").red() + ); + } + + if show_next { + println!(); + println!("{}", "Next".bold()); + println!( + " {} Run {} to apply these changes.", + "•".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; + } + let total = apply_result.changes.len(); + println!("{} {}", "Changes".bold(), format!("({total})").dimmed()); + 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(), + }; + 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); + } + } + } +} + +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 let Some(details) = &change.details { + for detail in details { + println!(" {} {}", "└".dimmed(), detail.dimmed()); + } + } +} + +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/link.rs b/src/commands/link.rs index 4a6cd7e6e..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}, + util::prompt::{ + fake_select, prompt_options, prompt_options_skippable, prompt_select, + prompt_text_with_placeholder_if_blank, + }, workspace::{Project, Workspace, workspaces}, }; @@ -71,6 +74,100 @@ 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 Railway project"), + LinkProjectChoice::New => write!(f, "Create a new Railway project"), + } + } +} + +pub async fn link_project_without_service() -> Result { + let mut configs = Configs::new()?; + let workspaces = workspaces().await?; + + let choice = if std::io::stdout().is_terminal() { + prompt_select( + "Where should Railway apply this configuration?", + vec![LinkProjectChoice::Existing, LinkProjectChoice::New], + )? + } else { + LinkProjectChoice::Existing + }; + + 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 +} + pub async fn command(args: Args) -> Result<()> { link_command(args, false).await } @@ -149,6 +246,29 @@ async fn link_command(args: Args, require_service: bool) -> 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, service_ids: &HashSet, diff --git a/src/commands/login.rs b/src/commands/login.rs index a01709755..e285e66f0 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -22,6 +22,10 @@ pub struct Args { pub browserless: bool, } +pub async fn prompt_login() -> Result<()> { + command(Args { browserless: false }).await +} + pub async fn command(args: Args) -> Result<()> { if !crate::macros::is_stdout_terminal() { if args.browserless { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index bd6cdc9be..dd44ef6c5 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod add; pub mod autoupdate; pub mod bucket; pub mod completion; +pub mod config; pub mod connect; pub mod delete; pub mod deploy; diff --git a/src/macros.rs b/src/macros.rs index cf1d12877..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,8 +40,9 @@ macro_rules! commands { s = s.name(stringify!($module)); s }; + let command_name = stringify!($module); // Add this subcommand into the global CLI. - cmd = cmd.subcommand(sub); + cmd = cmd.subcommand(sub.name(command_name)); } )* cmd = cmd @@ -75,13 +76,14 @@ macro_rules! commands { } if parts.is_empty() { None } else { Some(parts.join(":")) } }; - 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: stringify!([<$module:snake>]).to_string(), + command: command_name.to_string(), sub_command: subcommand_name, success: result.is_ok(), error_message: result.as_ref().err().map(|e| { diff --git a/src/main.rs b/src/main.rs index 271ee407d..15d61c31b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ commands!( autoupdate, bucket, completion, + config, connect, delete, deploy, diff --git a/src/util/progress.rs b/src/util/progress.rs index 04c9e4876..ec6402abd 100644 --- a/src/util/progress.rs +++ b/src/util/progress.rs @@ -91,5 +91,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}")); }