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    // Anything not defined below is handed to the shell implementation, so the
18    // port never takes a command away.
19    allow_external_subcommands = true,
20    // Says which implementation answered: both are called rst, and which one
21    // wins depends on PATH order.
22    version = concat!(env!("CARGO_PKG_VERSION"), " (rust)"),
23    about = "Dockerised local stacks: API, web apps, mobile, database, dashboard"
24)]
25struct Cli {
26    #[command(subcommand)]
27    command: Option<Command>,
28}
29
30#[derive(Subcommand)]
31enum Command {
32    /// Build if needed and start the stack
33    #[command(visible_alias = "run")]
34    Up {
35        /// Only these services
36        services: Vec<String>,
37        /// Rebuild images first
38        #[arg(long)]
39        build: bool,
40        /// Only services enabled under config `[essentials]`
41        #[arg(long)]
42        essential: bool,
43    },
44    /// Stop everything, keep data
45    Down { services: Vec<String> },
46    /// Service status
47    #[command(visible_alias = "status")]
48    Ps { services: Vec<String> },
49    /// Follow logs
50    Logs { services: Vec<String> },
51    /// Stop then start
52    Restart {
53        services: Vec<String>,
54        #[arg(long)]
55        build: bool,
56        /// Only services enabled under config `[essentials]`
57        #[arg(long)]
58        essential: bool,
59    },
60    /// Open a shell in a container
61    #[command(visible_alias = "sh")]
62    Shell {
63        #[arg(default_value = "backend")]
64        service: String,
65    },
66    /// List the apps this workspace runs
67    #[command(visible_alias = "list")]
68    Apps,
69    /// Host ports and the URLs that must match them
70    Ports,
71    /// Print the resolved configuration
72    Config,
73    /// Print the docker compose command instead of running it
74    Explain { command: Vec<String> },
75    /// Print the environment compose is given: one key, or all of it
76    Env { key: Option<String> },
77    /// What is ported so far, and what is not
78    Ported,
79    /// List every CLI command in a table
80    Commands {
81        /// One canonical command name per line
82        #[arg(long)]
83        raw: bool,
84    },
85    /// Install the latest run-stack from crates.io and migrate configs
86    #[command(visible_alias = "selfupdate")]
87    SelfUpdate {
88        /// Pass --verbose to cargo
89        #[arg(long, short = 'V')]
90        verbose: bool,
91    },
92    /// Anything the shell implementation still owns
93    #[command(external_subcommand)]
94    Delegated(Vec<String>),
95    /// Write the generated compose overlays without starting anything
96    Generate,
97    /// Check the workspace for faults, and repair the ones that are unambiguous
98    Doctor {
99        /// Apply the repairs instead of only reporting them
100        #[arg(long)]
101        fix: bool,
102        /// With --fix, show what would change without writing anything
103        #[arg(long)]
104        dry_run: bool,
105    },
106}
107
108fn run_doctor(workspace: &Workspace, fix: bool, dry_run: bool) -> Result<i32> {
109    println!("run-stack doctor — {}\n", workspace.root.display());
110
111    let checks = crate::doctor::run(workspace)?;
112    println!("{}", crate::doctor::format_checks(&checks));
113
114    if !fix {
115        let failed = checks
116            .iter()
117            .any(|check| check.status == crate::doctor::Status::Fail);
118        return Ok(if failed { 1 } else { 0 });
119    }
120
121    println!();
122    let actions = crate::doctor::fix(workspace, dry_run)?;
123    println!("{}", crate::doctor::format_actions(&actions, dry_run));
124
125    let failed = actions
126        .iter()
127        .any(|action| action.outcome == crate::doctor::Repair::Failed);
128    Ok(if failed { 1 } else { 0 })
129}
130
131#[allow(dead_code)]
132const UNUSED_NOT_PORTED: &[(&str, &str)] = &[
133    ("init", "the prompts and the app scan"),
134    ("create", "workspace setup"),
135    ("migrate", "layout conversion"),
136    ("clean", "volume deletion"),
137    ("rebuild", "image rebuild"),
138    ("dash", "dashboard"),
139    ("ios", "simulator launch"),
140    ("android", "emulator launch"),
141    ("device", "device launch"),
142    ("mobile", "metro restart"),
143    ("reload", "metro reload"),
144    ("prebuild", "expo prebuild"),
145    ("desktop", "electron / tauri shell"),
146    ("deploy", "deploy targets"),
147    ("backend", "commands in the API container"),
148    ("artisan", "laravel"),
149    ("composer", "laravel"),
150    ("pnpm", "workspace package manager"),
151    ("seed", "database seeders"),
152    ("fresh", "schema rebuild"),
153    ("services", "compose service table"),
154    ("completion", "shell completion"),
155];
156
157pub fn main() {
158    let code = match run() {
159        Ok(code) => code,
160        Err(error) => {
161            eprintln!("error: {error:#}");
162            1
163        }
164    };
165    std::process::exit(code);
166}
167
168fn run() -> Result<i32> {
169    let cli = Cli::parse();
170    let Some(command) = cli.command else {
171        print_status();
172        return Ok(0);
173    };
174
175    if let Command::Ported = command {
176        print_status();
177        return Ok(0);
178    }
179
180    if let Command::Commands { raw } = command {
181        return crate::commands::run(raw);
182    }
183
184    if let Command::SelfUpdate { verbose } = command {
185        return crate::self_update::run(verbose);
186    }
187
188    // The shell version needs to know which workspace, but must be allowed to
189    // run outside one: `create` makes the workspace in the first place.
190    if let Command::Delegated(argv) = &command {
191        let (name, rest) = argv.split_first().expect("clap yields a name");
192        let workspace = Workspace::find(&std::env::current_dir()?).ok();
193        // An app is a command of its own: `rst mobile-driver` starts it, so a
194        // workspace with several apps does not need `up` in front of each.
195        if let Some(workspace) = workspace
196            .as_ref()
197            .filter(|_| !crate::delegate::is_pending(name))
198        {
199            if is_service(workspace, name) {
200                let build = rest.iter().any(|arg| arg == "--build");
201                return start(workspace, vec![name.clone()], build);
202            }
203        }
204        return crate::delegate::run(name, rest, workspace.as_ref());
205    }
206
207    let workspace = Workspace::find(&std::env::current_dir()?)?;
208
209    match command {
210        Command::Ported
211        | Command::Commands { .. }
212        | Command::Delegated(_)
213        | Command::SelfUpdate { .. } => unreachable!("handled above"),
214        Command::Config => {
215            print!("{}", workspace.config()?.to_toml());
216            Ok(0)
217        }
218        Command::Apps => {
219            print_apps(&workspace)?;
220            Ok(0)
221        }
222        Command::Ports => {
223            let config = workspace.config()?;
224            for key in config.keys().filter(|key| key.ends_with("_PORT")) {
225                println!("{:<24} {}", key, config.port(key, 0));
226            }
227            Ok(0)
228        }
229        Command::Generate => {
230            regenerate(&workspace)?;
231            println!("wrote the overlays in {}", workspace.run_dir.display());
232            Ok(0)
233        }
234        Command::Doctor { fix, dry_run } => run_doctor(&workspace, fix, dry_run),
235        Command::Env { key } => {
236            let mut env = Env::load(&workspace.env_path())?;
237            env.derive(&workspace.root);
238            match key {
239                Some(key) => println!("{}", env.get(&key).unwrap_or("")),
240                None => {
241                    for (key, value) in env.iter() {
242                        println!("{key}={value}");
243                    }
244                }
245            }
246            Ok(0)
247        }
248        Command::Explain { command } => {
249            let compose = compose_for(&workspace)?;
250            println!("docker {}", compose.args(&command).join(" "));
251            Ok(0)
252        }
253        Command::Up {
254            services,
255            build,
256            essential,
257        } => {
258            let services = resolve_services(&workspace, services, essential)?;
259            start(&workspace, services, build)
260        }
261        Command::Down { services } => {
262            crate::compose::require_docker()?;
263            let mut args = vec!["down".to_string()];
264            args.extend(services);
265            compose_for(&workspace)?.run(&args)
266        }
267        Command::Ps { services } => {
268            crate::compose::require_docker()?;
269            let mut args = vec!["ps".to_string()];
270            args.extend(services);
271            compose_for(&workspace)?.run(&args)
272        }
273        Command::Logs { services } => {
274            crate::compose::require_docker()?;
275            let mut args = vec![
276                "logs".to_string(),
277                "-f".to_string(),
278                "--tail=100".to_string(),
279            ];
280            args.extend(services);
281            compose_for(&workspace)?.run(&args)
282        }
283        Command::Restart {
284            services,
285            build,
286            essential,
287        } => {
288            crate::compose::require_docker()?;
289            regenerate(&workspace)?;
290            let services = resolve_services(&workspace, services, essential)?;
291            let compose = compose_for(&workspace)?;
292            let mut down = vec!["down".to_string()];
293            down.extend(services.clone());
294            compose.run(&down)?;
295            let mut up = vec!["up".to_string(), "-d".to_string()];
296            if build {
297                up.push("--build".into());
298            }
299            up.extend(services);
300            compose.run(&up)
301        }
302        Command::Shell { service } => {
303            crate::compose::require_docker()?;
304            let compose = compose_for(&workspace)?;
305            let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
306            match compose.run(&bash)? {
307                0 => Ok(0),
308                // Plenty of images have no bash.
309                _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
310            }
311        }
312    }
313}
314
315/// The overlays depend on what the workspace holds right now, so they are
316/// written before every start rather than committed.
317fn regenerate(workspace: &Workspace) -> Result<()> {
318    let mut env = Env::load(&workspace.env_path())?;
319    env.derive(&workspace.root);
320    generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
321}
322
323fn compose_for(workspace: &Workspace) -> Result<Compose> {
324    let mut env = Env::load(&workspace.env_path())?;
325    env.derive(&workspace.root);
326    Compose::new(workspace, env)
327}
328
329/// Build what is missing and bring the services up. An empty list is the whole
330/// stack, which is the only case that may sweep orphans.
331fn start(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
332    crate::compose::require_docker()?;
333    regenerate(workspace)?;
334    let mut args = vec!["up".to_string(), "-d".to_string()];
335    if build {
336        args.push("--build".into());
337    }
338    if services.is_empty() {
339        args.push("--remove-orphans".into());
340    }
341    args.extend(services);
342    compose_for(workspace)?.run(&args)
343}
344
345/// Whether the workspace defines a compose service by that name. The registry
346/// the last `up` wrote answers without docker; docker itself is the fallback
347/// for a workspace that has never been started.
348fn is_service(workspace: &Workspace, name: &str) -> bool {
349    let cached = crate::compose::cached_services(&workspace.root);
350    if !cached.is_empty() {
351        return cached.iter().any(|service| service == name);
352    }
353    compose_for(workspace)
354        .map(|compose| compose.service_names().iter().any(|service| service == name))
355        .unwrap_or(false)
356}
357
358fn resolve_services(
359    workspace: &Workspace,
360    services: Vec<String>,
361    essential: bool,
362) -> Result<Vec<String>> {
363    if !essential {
364        return Ok(services);
365    }
366    if !services.is_empty() {
367        anyhow::bail!("pass service names or --essential, not both");
368    }
369    let listed = workspace.config()?.essential_services();
370    if listed.is_empty() {
371        anyhow::bail!(
372            "no essential services configured — add them under \"[essentials]\" in run.config.toml"
373        );
374    }
375    Ok(listed)
376}
377
378fn print_apps(workspace: &Workspace) -> Result<()> {
379    let config = workspace.config()?;
380    println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
381    println!(
382        "{:<10} {:<24} {:<6}",
383        "backend",
384        config.str_or("BACKEND_STACK", "laravel"),
385        config.port("BACKEND_PORT", 8000)
386    );
387    println!(
388        "{:<10} {:<24} {:<6}",
389        "web",
390        config.str_or("WEB_APP", "web"),
391        config.port("WEB_PORT", 5173)
392    );
393    for (flag, app_key, port_key, role, default_port) in [
394        ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
395        ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
396        ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
397        ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
398    ] {
399        if config.bool_or(flag, false) {
400            println!(
401                "{:<10} {:<24} {:<6}",
402                role,
403                config.str_or(app_key, role),
404                config.port(port_key, default_port)
405            );
406        }
407    }
408    for app in config.extra_apps() {
409        let port_key = format!("{}_PORT", crate::config::key_of(&app));
410        println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
411    }
412    Ok(())
413}
414
415fn print_status() {
416    println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
417    println!("Ported:");
418    for line in [
419        "up [--build] [--essential] [svc...]  start the stack",
420        "down [service...]           stop it, keep data",
421        "ps / status [service...]    service status",
422        "logs [service...]           follow logs",
423        "restart [--build] [--essential] [svc...]  down then up",
424        "shell / sh [service]        shell into a container",
425        "apps / list                 the apps this workspace runs",
426        "ports                       host ports",
427        "config                      the resolved configuration",
428        "explain <compose args>      print the docker command, run nothing",
429        "env [KEY]                   the environment compose is given",
430        "generate                    write the compose overlays, start nothing",
431        "commands [--raw]            every CLI command in a table",
432        "self-update [--verbose]     install latest from crates.io, migrate configs",
433    ] {
434        println!("  {line}");
435    }
436    println!("\nHanded to the shell implementation, transparently:");
437    let mut line = String::from("  ");
438    for (name, _) in crate::delegate::PENDING {
439        if line.len() + name.len() + 2 > 76 {
440            println!("{line}");
441            line = String::from("  ");
442        }
443        line.push_str(name);
444        line.push_str(", ");
445    }
446    println!("{}", line.trim_end_matches(", "));
447}