1use 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 allow_external_subcommands = true,
20 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 #[command(visible_alias = "run")]
34 Up {
35 services: Vec<String>,
37 #[arg(long)]
39 build: bool,
40 #[arg(long)]
42 essential: bool,
43 #[arg(long)]
45 no_start: bool,
46 },
47 Down { services: Vec<String> },
49 #[command(visible_alias = "status")]
51 Ps { services: Vec<String> },
52 Logs { services: Vec<String> },
54 Restart {
56 services: Vec<String>,
57 #[arg(long)]
58 build: bool,
59 #[arg(long)]
61 essential: bool,
62 },
63 #[command(visible_alias = "sh")]
65 Shell {
66 #[arg(default_value = "backend")]
67 service: String,
68 },
69 #[command(visible_alias = "list")]
71 Apps,
72 Ports,
74 Config,
76 Explain { command: Vec<String> },
78 Env { key: Option<String> },
80 Ported,
82 Commands {
84 #[arg(long)]
86 raw: bool,
87 },
88 #[command(visible_alias = "selfupdate")]
90 SelfUpdate {
91 #[arg(long, short = 'V')]
93 verbose: bool,
94 },
95 #[command(external_subcommand)]
97 Delegated(Vec<String>),
98 Generate,
100 Doctor {
102 #[arg(long)]
104 fix: bool,
105 #[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 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 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 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
318 }
319 }
320 }
321}
322
323fn 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
337fn start(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
340 up(workspace, services, build, false)
341}
342
343fn 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 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 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
418fn 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}