Skip to main content

run_stack/
cli.rs

1//! run-stack: one command to boot a full local stack in Docker.
2//!
3//! A port of the shell version in ../run. Commands that are not ported yet say
4//! so and name the one to use instead, rather than failing as unknown.
5
6use anyhow::Result;
7use clap::{Parser, Subcommand};
8
9use crate::compose::Compose;
10use crate::generate;
11use crate::env::Env;
12use crate::workspace::Workspace;
13
14#[derive(Parser)]
15#[command(
16    name = "run-stack",
17    // Says which implementation answered: both are called rst, and which one
18    // wins depends on PATH order.
19    version = concat!(env!("CARGO_PKG_VERSION"), " (rust)"),
20    about = "Dockerised local stacks: API, web apps, mobile, database, dashboard"
21)]
22struct Cli {
23    #[command(subcommand)]
24    command: Option<Command>,
25}
26
27#[derive(Subcommand)]
28enum Command {
29    /// Build if needed and start the stack
30    #[command(visible_alias = "run")]
31    Up {
32        /// Only these services
33        services: Vec<String>,
34        /// Rebuild images first
35        #[arg(long)]
36        build: bool,
37    },
38    /// Stop everything, keep data
39    Down { services: Vec<String> },
40    /// Service status
41    #[command(visible_alias = "status")]
42    Ps { services: Vec<String> },
43    /// Follow logs
44    Logs { services: Vec<String> },
45    /// Stop then start
46    Restart {
47        services: Vec<String>,
48        #[arg(long)]
49        build: bool,
50    },
51    /// Open a shell in a container
52    #[command(visible_alias = "sh")]
53    Shell {
54        #[arg(default_value = "backend")]
55        service: String,
56    },
57    /// List the apps this workspace runs
58    #[command(visible_alias = "list")]
59    Apps,
60    /// Host ports and the URLs that must match them
61    Ports,
62    /// Print the resolved configuration
63    Config,
64    /// Print the docker compose command instead of running it
65    Explain { command: Vec<String> },
66    /// Print the environment compose is given: one key, or all of it
67    Env { key: Option<String> },
68    /// What is ported so far, and what is not
69    Commands,
70    /// Write the generated compose overlays without starting anything
71    Generate,
72}
73
74/// Commands that still belong to the shell version, with a word on why.
75const NOT_PORTED: &[(&str, &str)] = &[
76    ("init", "the prompts and the app scan"),
77    ("create", "workspace setup"),
78    ("migrate", "layout conversion"),
79    ("clean", "volume deletion"),
80    ("rebuild", "image rebuild"),
81    ("dash", "dashboard"),
82    ("ios", "simulator launch"),
83    ("android", "emulator launch"),
84    ("device", "device launch"),
85    ("mobile", "metro restart"),
86    ("reload", "metro reload"),
87    ("prebuild", "expo prebuild"),
88    ("desktop", "electron / tauri shell"),
89    ("deploy", "deploy targets"),
90    ("backend", "commands in the API container"),
91    ("artisan", "laravel"),
92    ("composer", "laravel"),
93    ("pnpm", "workspace package manager"),
94    ("seed", "database seeders"),
95    ("fresh", "schema rebuild"),
96    ("services", "compose service table"),
97    ("self-update", "npm / pnpm update"),
98    ("completion", "shell completion"),
99];
100
101pub fn main() {
102    let code = match run() {
103        Ok(code) => code,
104        Err(error) => {
105            eprintln!("error: {error:#}");
106            1
107        }
108    };
109    std::process::exit(code);
110}
111
112fn run() -> Result<i32> {
113    let cli = Cli::parse();
114    let Some(command) = cli.command else {
115        print_status();
116        return Ok(0);
117    };
118
119    if let Command::Commands = command {
120        print_status();
121        return Ok(0);
122    }
123
124    let workspace = Workspace::find(&std::env::current_dir()?)?;
125
126    match command {
127        Command::Commands => unreachable!("handled above"),
128        Command::Config => {
129            print!("{}", workspace.config()?.to_json());
130            Ok(0)
131        }
132        Command::Apps => {
133            print_apps(&workspace)?;
134            Ok(0)
135        }
136        Command::Ports => {
137            let config = workspace.config()?;
138            for key in config.keys().filter(|key| key.ends_with("_PORT")) {
139                println!("{:<24} {}", key, config.port(key, 0));
140            }
141            Ok(0)
142        }
143        Command::Generate => {
144            regenerate(&workspace)?;
145            println!("wrote the overlays in {}", workspace.run_dir.display());
146            Ok(0)
147        }
148        Command::Env { key } => {
149            let mut env = Env::load(&workspace.env_path())?;
150            env.derive(&workspace.root);
151            match key {
152                Some(key) => println!("{}", env.get(&key).unwrap_or("")),
153                None => {
154                    for (key, value) in env.iter() {
155                        println!("{key}={value}");
156                    }
157                }
158            }
159            Ok(0)
160        }
161        Command::Explain { command } => {
162            let compose = compose_for(&workspace)?;
163            println!("docker {}", compose.args(&command).join(" "));
164            Ok(0)
165        }
166        Command::Up { services, build } => {
167            crate::compose::require_docker()?;
168            regenerate(&workspace)?;
169            let mut args = vec!["up".to_string(), "-d".to_string()];
170            if build {
171                args.push("--build".into());
172            }
173            if services.is_empty() {
174                args.push("--remove-orphans".into());
175            }
176            args.extend(services);
177            compose_for(&workspace)?.run(&args)
178        }
179        Command::Down { services } => {
180            crate::compose::require_docker()?;
181            let mut args = vec!["down".to_string()];
182            args.extend(services);
183            compose_for(&workspace)?.run(&args)
184        }
185        Command::Ps { services } => {
186            crate::compose::require_docker()?;
187            let mut args = vec!["ps".to_string()];
188            args.extend(services);
189            compose_for(&workspace)?.run(&args)
190        }
191        Command::Logs { services } => {
192            crate::compose::require_docker()?;
193            let mut args = vec![
194                "logs".to_string(),
195                "-f".to_string(),
196                "--tail=100".to_string(),
197            ];
198            args.extend(services);
199            compose_for(&workspace)?.run(&args)
200        }
201        Command::Restart { services, build } => {
202            crate::compose::require_docker()?;
203            regenerate(&workspace)?;
204            let compose = compose_for(&workspace)?;
205            let mut down = vec!["down".to_string()];
206            down.extend(services.clone());
207            compose.run(&down)?;
208            let mut up = vec!["up".to_string(), "-d".to_string()];
209            if build {
210                up.push("--build".into());
211            }
212            up.extend(services);
213            compose.run(&up)
214        }
215        Command::Shell { service } => {
216            crate::compose::require_docker()?;
217            let compose = compose_for(&workspace)?;
218            let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
219            match compose.run(&bash)? {
220                0 => Ok(0),
221                // Plenty of images have no bash.
222                _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
223            }
224        }
225    }
226}
227
228/// The overlays depend on what the workspace holds right now, so they are
229/// written before every start rather than committed.
230fn regenerate(workspace: &Workspace) -> Result<()> {
231    let mut env = Env::load(&workspace.env_path())?;
232    env.derive(&workspace.root);
233    generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
234}
235
236fn compose_for(workspace: &Workspace) -> Result<Compose> {
237    let mut env = Env::load(&workspace.env_path())?;
238    env.derive(&workspace.root);
239    Compose::new(workspace, env)
240}
241
242fn print_apps(workspace: &Workspace) -> Result<()> {
243    let config = workspace.config()?;
244    println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
245    println!(
246        "{:<10} {:<24} {:<6}",
247        "backend",
248        config.str_or("BACKEND_STACK", "laravel"),
249        config.port("BACKEND_PORT", 8000)
250    );
251    println!(
252        "{:<10} {:<24} {:<6}",
253        "web",
254        config.str_or("WEB_APP", "web"),
255        config.port("WEB_PORT", 5173)
256    );
257    for (flag, app_key, port_key, role, default_port) in [
258        ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
259        ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
260        ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
261        ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
262    ] {
263        if config.bool_or(flag, false) {
264            println!(
265                "{:<10} {:<24} {:<6}",
266                role,
267                config.str_or(app_key, role),
268                config.port(port_key, default_port)
269            );
270        }
271    }
272    for app in config.extra_apps() {
273        let port_key = format!("{}_PORT", crate::config::key_of(&app));
274        println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
275    }
276    Ok(())
277}
278
279fn print_status() {
280    println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
281    println!("Ported:");
282    for line in [
283        "up [--build] [service...]   start the stack",
284        "down [service...]           stop it, keep data",
285        "ps / status [service...]    service status",
286        "logs [service...]           follow logs",
287        "restart [--build] [svc...]  down then up",
288        "shell / sh [service]        shell into a container",
289        "apps / list                 the apps this workspace runs",
290        "ports                       host ports",
291        "config                      the resolved configuration",
292        "explain <compose args>      print the docker command, run nothing",
293        "env [KEY]                   the environment compose is given",
294        "generate                    write the compose overlays, start nothing",
295    ] {
296        println!("  {line}");
297    }
298    println!("\nStill the shell version — use `rst <command>`:");
299    let mut line = String::from("  ");
300    for (name, _) in NOT_PORTED {
301        if line.len() + name.len() + 2 > 76 {
302            println!("{line}");
303            line = String::from("  ");
304        }
305        line.push_str(name);
306        line.push_str(", ");
307    }
308    println!("{}", line.trim_end_matches(", "));
309}