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