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 Doctor {
107 #[arg(long)]
109 fix: bool,
110 #[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 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 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 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 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
345 }
346 }
347 }
348}
349
350fn 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
364fn start(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
367 up(workspace, services, build, false)
368}
369
370fn 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 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 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
445fn 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}