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