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 Add {
102 folder: String,
104 },
105 Remove {
107 folder: String,
109 },
110 Doctor {
112 #[arg(long)]
114 fix: bool,
115 #[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 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 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 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 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 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
374 }
375 }
376 }
377}
378
379fn 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
393fn start(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
396 up(workspace, services, build, false)
397}
398
399fn 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 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 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
474fn 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 Ok(())
542}
543
544fn print_status() {
545 println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
546 println!("Ported:");
547 for line in [
548 "up [--build] [--essential] [svc...] start the stack",
549 "down [service...] stop it, keep data",
550 "ps / status [service...] service status",
551 "logs [service...] follow logs",
552 "restart [--build] [--essential] [svc...] down then up",
553 "shell / sh [service] shell into a container",
554 "apps / list the apps this workspace runs",
555 "ports host ports",
556 "config the resolved configuration",
557 "explain <compose args> print the docker command, run nothing",
558 "env [KEY] the environment compose is given",
559 "generate write the compose overlays, start nothing",
560 "commands [--raw] every CLI command in a table",
561 "self-update [--verbose] install latest from crates.io, migrate configs",
562 ] {
563 println!(" {line}");
564 }
565 println!("\nHanded to the shell implementation, transparently:");
566 let mut line = String::from(" ");
567 for (name, _) in crate::delegate::PENDING {
568 if line.len() + name.len() + 2 > 76 {
569 println!("{line}");
570 line = String::from(" ");
571 }
572 line.push_str(name);
573 line.push_str(", ");
574 }
575 println!("{}", line.trim_end_matches(", "));
576}