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