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