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 the services listed under config `essential`
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 the services listed under config `essential`
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}
98
99#[allow(dead_code)]
100const UNUSED_NOT_PORTED: &[(&str, &str)] = &[
101    ("init", "the prompts and the app scan"),
102    ("create", "workspace setup"),
103    ("migrate", "layout conversion"),
104    ("clean", "volume deletion"),
105    ("rebuild", "image rebuild"),
106    ("dash", "dashboard"),
107    ("ios", "simulator launch"),
108    ("android", "emulator launch"),
109    ("device", "device launch"),
110    ("mobile", "metro restart"),
111    ("reload", "metro reload"),
112    ("prebuild", "expo prebuild"),
113    ("desktop", "electron / tauri shell"),
114    ("deploy", "deploy targets"),
115    ("backend", "commands in the API container"),
116    ("artisan", "laravel"),
117    ("composer", "laravel"),
118    ("pnpm", "workspace package manager"),
119    ("seed", "database seeders"),
120    ("fresh", "schema rebuild"),
121    ("services", "compose service table"),
122    ("completion", "shell completion"),
123];
124
125pub fn main() {
126    let code = match run() {
127        Ok(code) => code,
128        Err(error) => {
129            eprintln!("error: {error:#}");
130            1
131        }
132    };
133    std::process::exit(code);
134}
135
136fn run() -> Result<i32> {
137    let cli = Cli::parse();
138    let Some(command) = cli.command else {
139        print_status();
140        return Ok(0);
141    };
142
143    if let Command::Ported = command {
144        print_status();
145        return Ok(0);
146    }
147
148    if let Command::Commands { raw } = command {
149        return crate::commands::run(raw);
150    }
151
152    if let Command::SelfUpdate { verbose } = command {
153        return crate::self_update::run(verbose);
154    }
155
156    // The shell version needs to know which workspace, but must be allowed to
157    // run outside one: `create` makes the workspace in the first place.
158    if let Command::Delegated(argv) = &command {
159        let (name, rest) = argv.split_first().expect("clap yields a name");
160        let workspace = Workspace::find(&std::env::current_dir()?).ok();
161        return crate::delegate::run(name, rest, workspace.as_ref());
162    }
163
164    let workspace = Workspace::find(&std::env::current_dir()?)?;
165
166    match command {
167        Command::Ported
168        | Command::Commands { .. }
169        | Command::Delegated(_)
170        | Command::SelfUpdate { .. } => unreachable!("handled above"),
171        Command::Config => {
172            print!("{}", workspace.config()?.to_toml());
173            Ok(0)
174        }
175        Command::Apps => {
176            print_apps(&workspace)?;
177            Ok(0)
178        }
179        Command::Ports => {
180            let config = workspace.config()?;
181            for key in config.keys().filter(|key| key.ends_with("_PORT")) {
182                println!("{:<24} {}", key, config.port(key, 0));
183            }
184            Ok(0)
185        }
186        Command::Generate => {
187            regenerate(&workspace)?;
188            println!("wrote the overlays in {}", workspace.run_dir.display());
189            Ok(0)
190        }
191        Command::Env { key } => {
192            let mut env = Env::load(&workspace.env_path())?;
193            env.derive(&workspace.root);
194            match key {
195                Some(key) => println!("{}", env.get(&key).unwrap_or("")),
196                None => {
197                    for (key, value) in env.iter() {
198                        println!("{key}={value}");
199                    }
200                }
201            }
202            Ok(0)
203        }
204        Command::Explain { command } => {
205            let compose = compose_for(&workspace)?;
206            println!("docker {}", compose.args(&command).join(" "));
207            Ok(0)
208        }
209        Command::Up {
210            services,
211            build,
212            essential,
213        } => {
214            crate::compose::require_docker()?;
215            regenerate(&workspace)?;
216            let services = resolve_services(&workspace, services, essential)?;
217            let mut args = vec!["up".to_string(), "-d".to_string()];
218            if build {
219                args.push("--build".into());
220            }
221            if services.is_empty() {
222                args.push("--remove-orphans".into());
223            }
224            args.extend(services);
225            compose_for(&workspace)?.run(&args)
226        }
227        Command::Down { services } => {
228            crate::compose::require_docker()?;
229            let mut args = vec!["down".to_string()];
230            args.extend(services);
231            compose_for(&workspace)?.run(&args)
232        }
233        Command::Ps { services } => {
234            crate::compose::require_docker()?;
235            let mut args = vec!["ps".to_string()];
236            args.extend(services);
237            compose_for(&workspace)?.run(&args)
238        }
239        Command::Logs { services } => {
240            crate::compose::require_docker()?;
241            let mut args = vec![
242                "logs".to_string(),
243                "-f".to_string(),
244                "--tail=100".to_string(),
245            ];
246            args.extend(services);
247            compose_for(&workspace)?.run(&args)
248        }
249        Command::Restart {
250            services,
251            build,
252            essential,
253        } => {
254            crate::compose::require_docker()?;
255            regenerate(&workspace)?;
256            let services = resolve_services(&workspace, services, essential)?;
257            let compose = compose_for(&workspace)?;
258            let mut down = vec!["down".to_string()];
259            down.extend(services.clone());
260            compose.run(&down)?;
261            let mut up = vec!["up".to_string(), "-d".to_string()];
262            if build {
263                up.push("--build".into());
264            }
265            up.extend(services);
266            compose.run(&up)
267        }
268        Command::Shell { service } => {
269            crate::compose::require_docker()?;
270            let compose = compose_for(&workspace)?;
271            let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
272            match compose.run(&bash)? {
273                0 => Ok(0),
274                // Plenty of images have no bash.
275                _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
276            }
277        }
278    }
279}
280
281/// The overlays depend on what the workspace holds right now, so they are
282/// written before every start rather than committed.
283fn regenerate(workspace: &Workspace) -> Result<()> {
284    let mut env = Env::load(&workspace.env_path())?;
285    env.derive(&workspace.root);
286    generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
287}
288
289fn compose_for(workspace: &Workspace) -> Result<Compose> {
290    let mut env = Env::load(&workspace.env_path())?;
291    env.derive(&workspace.root);
292    Compose::new(workspace, env)
293}
294
295fn resolve_services(
296    workspace: &Workspace,
297    services: Vec<String>,
298    essential: bool,
299) -> Result<Vec<String>> {
300    if !essential {
301        return Ok(services);
302    }
303    if !services.is_empty() {
304        anyhow::bail!("pass service names or --essential, not both");
305    }
306    let listed = workspace.config()?.essential_services();
307    if listed.is_empty() {
308        anyhow::bail!(
309            "no essential services configured — add them under \"essential\" in run.config.toml"
310        );
311    }
312    Ok(listed)
313}
314
315fn print_apps(workspace: &Workspace) -> Result<()> {
316    let config = workspace.config()?;
317    println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
318    println!(
319        "{:<10} {:<24} {:<6}",
320        "backend",
321        config.str_or("BACKEND_STACK", "laravel"),
322        config.port("BACKEND_PORT", 8000)
323    );
324    println!(
325        "{:<10} {:<24} {:<6}",
326        "web",
327        config.str_or("WEB_APP", "web"),
328        config.port("WEB_PORT", 5173)
329    );
330    for (flag, app_key, port_key, role, default_port) in [
331        ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
332        ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
333        ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
334        ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
335    ] {
336        if config.bool_or(flag, false) {
337            println!(
338                "{:<10} {:<24} {:<6}",
339                role,
340                config.str_or(app_key, role),
341                config.port(port_key, default_port)
342            );
343        }
344    }
345    for app in config.extra_apps() {
346        let port_key = format!("{}_PORT", crate::config::key_of(&app));
347        println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
348    }
349    Ok(())
350}
351
352fn print_status() {
353    println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
354    println!("Ported:");
355    for line in [
356        "up [--build] [--essential] [svc...]  start the stack",
357        "down [service...]           stop it, keep data",
358        "ps / status [service...]    service status",
359        "logs [service...]           follow logs",
360        "restart [--build] [--essential] [svc...]  down then up",
361        "shell / sh [service]        shell into a container",
362        "apps / list                 the apps this workspace runs",
363        "ports                       host ports",
364        "config                      the resolved configuration",
365        "explain <compose args>      print the docker command, run nothing",
366        "env [KEY]                   the environment compose is given",
367        "generate                    write the compose overlays, start nothing",
368        "commands [--raw]            every CLI command in a table",
369        "self-update [--verbose]     install latest from crates.io, migrate configs",
370    ] {
371        println!("  {line}");
372    }
373    println!("\nHanded to the shell implementation, transparently:");
374    let mut line = String::from("  ");
375    for (name, _) in crate::delegate::PENDING {
376        if line.len() + name.len() + 2 > 76 {
377            println!("{line}");
378            line = String::from("  ");
379        }
380        line.push_str(name);
381        line.push_str(", ");
382    }
383    println!("{}", line.trim_end_matches(", "));
384}