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 written = 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 for line in &written {
133 println!(" {line}");
134 }
135 println!(" start it with: rst up {}", found.name);
136 Ok(0)
137}
138
139fn run_doctor(workspace: &Workspace, fix: bool, dry_run: bool) -> Result<i32> {
140 println!("run-stack doctor — {}\n", workspace.root.display());
141
142 let checks = crate::doctor::run(workspace)?;
143 println!("{}", crate::doctor::format_checks(&checks));
144
145 if !fix {
146 let failed = checks
147 .iter()
148 .any(|check| check.status == crate::doctor::Status::Fail);
149 return Ok(if failed { 1 } else { 0 });
150 }
151
152 println!();
153 let actions = crate::doctor::fix(workspace, dry_run)?;
154 println!("{}", crate::doctor::format_actions(&actions, dry_run));
155
156 let failed = actions
157 .iter()
158 .any(|action| action.outcome == crate::doctor::Repair::Failed);
159 Ok(if failed { 1 } else { 0 })
160}
161
162#[allow(dead_code)]
163const UNUSED_NOT_PORTED: &[(&str, &str)] = &[
164 ("init", "the prompts and the app scan"),
165 ("create", "workspace setup"),
166 ("migrate", "layout conversion"),
167 ("clean", "volume deletion"),
168 ("rebuild", "image rebuild"),
169 ("dash", "dashboard"),
170 ("ios", "simulator launch"),
171 ("android", "emulator launch"),
172 ("device", "device launch"),
173 ("mobile", "metro restart"),
174 ("reload", "metro reload"),
175 ("prebuild", "expo prebuild"),
176 ("desktop", "electron / tauri shell"),
177 ("deploy", "deploy targets"),
178 ("backend", "commands in the API container"),
179 ("artisan", "laravel"),
180 ("composer", "laravel"),
181 ("pnpm", "workspace package manager"),
182 ("seed", "database seeders"),
183 ("fresh", "schema rebuild"),
184 ("services", "compose service table"),
185 ("completion", "shell completion"),
186];
187
188pub fn main() {
189 let code = match run() {
190 Ok(code) => code,
191 Err(error) => {
192 eprintln!("error: {error:#}");
193 1
194 }
195 };
196 std::process::exit(code);
197}
198
199fn run() -> Result<i32> {
200 let cli = Cli::parse();
201 let Some(command) = cli.command else {
202 print_status();
203 return Ok(0);
204 };
205
206 if let Command::Ported = command {
207 print_status();
208 return Ok(0);
209 }
210
211 if let Command::Commands { raw } = command {
212 return crate::commands::run(raw);
213 }
214
215 if let Command::SelfUpdate { verbose } = command {
216 return crate::self_update::run(verbose);
217 }
218
219 if let Command::Delegated(argv) = &command {
222 let (name, rest) = argv.split_first().expect("clap yields a name");
223 let workspace = Workspace::find(&std::env::current_dir()?).ok();
224 if let Some(workspace) = workspace
227 .as_ref()
228 .filter(|_| !crate::delegate::is_pending(name))
229 {
230 if is_service(workspace, name) {
231 let build = rest.iter().any(|arg| arg == "--build");
232 return start(workspace, vec![name.clone()], build);
233 }
234 }
235 return crate::delegate::run(name, rest, workspace.as_ref());
236 }
237
238 let workspace = Workspace::find(&std::env::current_dir()?)?;
239
240 match command {
241 Command::Ported
242 | Command::Commands { .. }
243 | Command::Delegated(_)
244 | Command::SelfUpdate { .. } => unreachable!("handled above"),
245 Command::Config => {
246 print!("{}", workspace.config()?.to_toml());
247 Ok(0)
248 }
249 Command::Apps => {
250 print_apps(&workspace)?;
251 Ok(0)
252 }
253 Command::Ports => {
254 let config = workspace.config()?;
255 for key in config.keys().filter(|key| key.ends_with("_PORT")) {
256 println!("{:<24} {}", key, config.port(key, 0));
257 }
258 Ok(0)
259 }
260 Command::Generate => {
261 regenerate(&workspace)?;
262 println!("wrote the overlays in {}", workspace.run_dir.display());
263 Ok(0)
264 }
265 Command::Add { folder } => run_add(&workspace, &folder),
266 Command::Doctor { fix, dry_run } => run_doctor(&workspace, fix, dry_run),
267 Command::Env { key } => {
268 let mut env = Env::load(&workspace.env_path())?;
269 env.derive(&workspace.root);
270 match key {
271 Some(key) => println!("{}", env.get(&key).unwrap_or("")),
272 None => {
273 for (key, value) in env.iter() {
274 println!("{key}={value}");
275 }
276 }
277 }
278 Ok(0)
279 }
280 Command::Explain { command } => {
281 let compose = compose_for(&workspace)?;
282 println!("docker {}", compose.args(&command).join(" "));
283 Ok(0)
284 }
285 Command::Up {
286 services,
287 build,
288 essential,
289 no_start,
290 } => {
291 let services = resolve_services(&workspace, services, essential)?;
292 if no_start {
293 create(&workspace, services, build)
294 } else {
295 start(&workspace, services, build)
296 }
297 }
298 Command::Down { services } => {
299 crate::compose::require_docker()?;
300 let mut args = vec!["down".to_string()];
301 args.extend(services);
302 compose_for(&workspace)?.run(&args)
303 }
304 Command::Ps { services } => {
305 crate::compose::require_docker()?;
306 let mut args = vec!["ps".to_string()];
307 args.extend(services);
308 compose_for(&workspace)?.run(&args)
309 }
310 Command::Logs { services } => {
311 crate::compose::require_docker()?;
312 let mut args = vec![
313 "logs".to_string(),
314 "-f".to_string(),
315 "--tail=100".to_string(),
316 ];
317 args.extend(services);
318 compose_for(&workspace)?.run(&args)
319 }
320 Command::Restart {
321 services,
322 build,
323 essential,
324 } => {
325 crate::compose::require_docker()?;
326 regenerate(&workspace)?;
327 let services = resolve_services(&workspace, services, essential)?;
328 let compose = compose_for(&workspace)?;
329 let mut down = vec!["down".to_string()];
330 down.extend(services.clone());
331 compose.run(&down)?;
332 let mut up = vec!["up".to_string(), "-d".to_string()];
333 if build {
334 up.push("--build".into());
335 }
336 up.extend(services);
337 compose.run(&up)
338 }
339 Command::Shell { service } => {
340 crate::compose::require_docker()?;
341 let compose = compose_for(&workspace)?;
342 let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
343 match compose.run(&bash)? {
344 0 => Ok(0),
345 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
347 }
348 }
349 }
350}
351
352fn regenerate(workspace: &Workspace) -> Result<()> {
355 let mut env = Env::load(&workspace.env_path())?;
356 env.derive(&workspace.root);
357 generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
358}
359
360fn compose_for(workspace: &Workspace) -> Result<Compose> {
361 let mut env = Env::load(&workspace.env_path())?;
362 env.derive(&workspace.root);
363 Compose::new(workspace, env)
364}
365
366fn start(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
369 up(workspace, services, build, false)
370}
371
372fn create(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
375 up(workspace, services, build, true)
376}
377
378fn up(workspace: &Workspace, services: Vec<String>, build: bool, no_start: bool) -> Result<i32> {
379 crate::compose::require_docker()?;
380 regenerate(workspace)?;
381 compose_for(workspace)?.run(&up_args(services, build, no_start))
382}
383
384fn up_args(services: Vec<String>, build: bool, no_start: bool) -> Vec<String> {
385 let mode = if no_start { "--no-start" } else { "-d" };
388 let mut args = vec!["up".to_string(), mode.to_string()];
389 if build {
390 args.push("--build".into());
391 }
392 if services.is_empty() {
393 args.push("--remove-orphans".into());
394 }
395 args.extend(services);
396 args
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn args(services: &[&str], build: bool, no_start: bool) -> Vec<String> {
404 up_args(services.iter().map(|s| (*s).to_string()).collect(), build, no_start)
405 }
406
407 #[test]
408 fn up_runs_detached_by_default() {
409 let built = args(&[], false, false);
410
411 assert!(built.contains(&"-d".to_string()));
412 assert!(!built.contains(&"--no-start".to_string()));
413 }
414
415 #[test]
416 fn no_start_creates_without_running() {
417 let built = args(&[], false, true);
418
419 assert!(built.contains(&"--no-start".to_string()));
420 assert!(!built.contains(&"-d".to_string()));
423 }
424
425 #[test]
426 fn named_services_are_kept_and_orphans_left_alone() {
427 let built = args(&["web", "backend"], false, true);
428
429 assert!(built.ends_with(&["web".to_string(), "backend".to_string()]));
430 assert!(!built.contains(&"--remove-orphans".to_string()));
431 }
432
433 #[test]
434 fn a_whole_stack_prunes_orphans() {
435 assert!(args(&[], false, false).contains(&"--remove-orphans".to_string()));
436 }
437
438 #[test]
439 fn build_survives_either_mode() {
440 assert!(args(&[], true, false).contains(&"--build".to_string()));
441 assert!(args(&[], true, true).contains(&"--build".to_string()));
442 }
443}
444
445
446
447fn is_service(workspace: &Workspace, name: &str) -> bool {
451 let cached = crate::compose::cached_services(&workspace.root);
452 if !cached.is_empty() {
453 return cached.iter().any(|service| service == name);
454 }
455 compose_for(workspace)
456 .map(|compose| compose.service_names().iter().any(|service| service == name))
457 .unwrap_or(false)
458}
459
460fn resolve_services(
461 workspace: &Workspace,
462 services: Vec<String>,
463 essential: bool,
464) -> Result<Vec<String>> {
465 if !essential {
466 return Ok(services);
467 }
468 if !services.is_empty() {
469 anyhow::bail!("pass service names or --essential, not both");
470 }
471 let listed = workspace.config()?.essential_services();
472 if listed.is_empty() {
473 anyhow::bail!(
474 "no essential services configured — add them under \"[essentials]\" in run.config.toml"
475 );
476 }
477 Ok(listed)
478}
479
480fn print_apps(workspace: &Workspace) -> Result<()> {
481 let config = workspace.config()?;
482 println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
483 println!(
484 "{:<10} {:<24} {:<6}",
485 "backend",
486 config.str_or("BACKEND_STACK", "laravel"),
487 config.port("BACKEND_PORT", 8000)
488 );
489 println!(
490 "{:<10} {:<24} {:<6}",
491 "web",
492 config.str_or("WEB_APP", "web"),
493 config.port("WEB_PORT", 5173)
494 );
495 for (flag, app_key, port_key, role, default_port) in [
496 ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
497 ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
498 ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
499 ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
500 ] {
501 if config.bool_or(flag, false) {
502 println!(
503 "{:<10} {:<24} {:<6}",
504 role,
505 config.str_or(app_key, role),
506 config.port(port_key, default_port)
507 );
508 }
509 }
510 for app in config.extra_apps() {
511 let port_key = format!("{}_PORT", crate::config::key_of(&app));
512 println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
513 }
514 Ok(())
515}
516
517fn print_status() {
518 println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
519 println!("Ported:");
520 for line in [
521 "up [--build] [--essential] [svc...] start the stack",
522 "down [service...] stop it, keep data",
523 "ps / status [service...] service status",
524 "logs [service...] follow logs",
525 "restart [--build] [--essential] [svc...] down then up",
526 "shell / sh [service] shell into a container",
527 "apps / list the apps this workspace runs",
528 "ports host ports",
529 "config the resolved configuration",
530 "explain <compose args> print the docker command, run nothing",
531 "env [KEY] the environment compose is given",
532 "generate write the compose overlays, start nothing",
533 "commands [--raw] every CLI command in a table",
534 "self-update [--verbose] install latest from crates.io, migrate configs",
535 ] {
536 println!(" {line}");
537 }
538 println!("\nHanded to the shell implementation, transparently:");
539 let mut line = String::from(" ");
540 for (name, _) in crate::delegate::PENDING {
541 if line.len() + name.len() + 2 > 76 {
542 println!("{line}");
543 line = String::from(" ");
544 }
545 line.push_str(name);
546 line.push_str(", ");
547 }
548 println!("{}", line.trim_end_matches(", "));
549}