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 },
44 Down { services: Vec<String> },
46 #[command(visible_alias = "status")]
48 Ps { services: Vec<String> },
49 Logs { services: Vec<String> },
51 Restart {
53 services: Vec<String>,
54 #[arg(long)]
55 build: bool,
56 #[arg(long)]
58 essential: bool,
59 },
60 #[command(visible_alias = "sh")]
62 Shell {
63 #[arg(default_value = "backend")]
64 service: String,
65 },
66 #[command(visible_alias = "list")]
68 Apps,
69 Ports,
71 Config,
73 Explain { command: Vec<String> },
75 Env { key: Option<String> },
77 Ported,
79 Commands {
81 #[arg(long)]
83 raw: bool,
84 },
85 #[command(visible_alias = "selfupdate")]
87 SelfUpdate {
88 #[arg(long, short = 'V')]
90 verbose: bool,
91 },
92 #[command(external_subcommand)]
94 Delegated(Vec<String>),
95 Generate,
97 Doctor {
99 #[arg(long)]
101 fix: bool,
102 #[arg(long)]
104 dry_run: bool,
105 },
106}
107
108fn run_doctor(workspace: &Workspace, fix: bool, dry_run: bool) -> Result<i32> {
109 println!("run-stack doctor — {}\n", workspace.root.display());
110
111 let checks = crate::doctor::run(workspace)?;
112 println!("{}", crate::doctor::format_checks(&checks));
113
114 if !fix {
115 let failed = checks
116 .iter()
117 .any(|check| check.status == crate::doctor::Status::Fail);
118 return Ok(if failed { 1 } else { 0 });
119 }
120
121 println!();
122 let actions = crate::doctor::fix(workspace, dry_run)?;
123 println!("{}", crate::doctor::format_actions(&actions, dry_run));
124
125 let failed = actions
126 .iter()
127 .any(|action| action.outcome == crate::doctor::Repair::Failed);
128 Ok(if failed { 1 } else { 0 })
129}
130
131#[allow(dead_code)]
132const UNUSED_NOT_PORTED: &[(&str, &str)] = &[
133 ("init", "the prompts and the app scan"),
134 ("create", "workspace setup"),
135 ("migrate", "layout conversion"),
136 ("clean", "volume deletion"),
137 ("rebuild", "image rebuild"),
138 ("dash", "dashboard"),
139 ("ios", "simulator launch"),
140 ("android", "emulator launch"),
141 ("device", "device launch"),
142 ("mobile", "metro restart"),
143 ("reload", "metro reload"),
144 ("prebuild", "expo prebuild"),
145 ("desktop", "electron / tauri shell"),
146 ("deploy", "deploy targets"),
147 ("backend", "commands in the API container"),
148 ("artisan", "laravel"),
149 ("composer", "laravel"),
150 ("pnpm", "workspace package manager"),
151 ("seed", "database seeders"),
152 ("fresh", "schema rebuild"),
153 ("services", "compose service table"),
154 ("completion", "shell completion"),
155];
156
157pub fn main() {
158 let code = match run() {
159 Ok(code) => code,
160 Err(error) => {
161 eprintln!("error: {error:#}");
162 1
163 }
164 };
165 std::process::exit(code);
166}
167
168fn run() -> Result<i32> {
169 let cli = Cli::parse();
170 let Some(command) = cli.command else {
171 print_status();
172 return Ok(0);
173 };
174
175 if let Command::Ported = command {
176 print_status();
177 return Ok(0);
178 }
179
180 if let Command::Commands { raw } = command {
181 return crate::commands::run(raw);
182 }
183
184 if let Command::SelfUpdate { verbose } = command {
185 return crate::self_update::run(verbose);
186 }
187
188 if let Command::Delegated(argv) = &command {
191 let (name, rest) = argv.split_first().expect("clap yields a name");
192 let workspace = Workspace::find(&std::env::current_dir()?).ok();
193 if let Some(workspace) = workspace
196 .as_ref()
197 .filter(|_| !crate::delegate::is_pending(name))
198 {
199 if is_service(workspace, name) {
200 let build = rest.iter().any(|arg| arg == "--build");
201 return start(workspace, vec![name.clone()], build);
202 }
203 }
204 return crate::delegate::run(name, rest, workspace.as_ref());
205 }
206
207 let workspace = Workspace::find(&std::env::current_dir()?)?;
208
209 match command {
210 Command::Ported
211 | Command::Commands { .. }
212 | Command::Delegated(_)
213 | Command::SelfUpdate { .. } => unreachable!("handled above"),
214 Command::Config => {
215 print!("{}", workspace.config()?.to_toml());
216 Ok(0)
217 }
218 Command::Apps => {
219 print_apps(&workspace)?;
220 Ok(0)
221 }
222 Command::Ports => {
223 let config = workspace.config()?;
224 for key in config.keys().filter(|key| key.ends_with("_PORT")) {
225 println!("{:<24} {}", key, config.port(key, 0));
226 }
227 Ok(0)
228 }
229 Command::Generate => {
230 regenerate(&workspace)?;
231 println!("wrote the overlays in {}", workspace.run_dir.display());
232 Ok(0)
233 }
234 Command::Doctor { fix, dry_run } => run_doctor(&workspace, fix, dry_run),
235 Command::Env { key } => {
236 let mut env = Env::load(&workspace.env_path())?;
237 env.derive(&workspace.root);
238 match key {
239 Some(key) => println!("{}", env.get(&key).unwrap_or("")),
240 None => {
241 for (key, value) in env.iter() {
242 println!("{key}={value}");
243 }
244 }
245 }
246 Ok(0)
247 }
248 Command::Explain { command } => {
249 let compose = compose_for(&workspace)?;
250 println!("docker {}", compose.args(&command).join(" "));
251 Ok(0)
252 }
253 Command::Up {
254 services,
255 build,
256 essential,
257 } => {
258 let services = resolve_services(&workspace, services, essential)?;
259 start(&workspace, services, build)
260 }
261 Command::Down { services } => {
262 crate::compose::require_docker()?;
263 let mut args = vec!["down".to_string()];
264 args.extend(services);
265 compose_for(&workspace)?.run(&args)
266 }
267 Command::Ps { services } => {
268 crate::compose::require_docker()?;
269 let mut args = vec!["ps".to_string()];
270 args.extend(services);
271 compose_for(&workspace)?.run(&args)
272 }
273 Command::Logs { services } => {
274 crate::compose::require_docker()?;
275 let mut args = vec![
276 "logs".to_string(),
277 "-f".to_string(),
278 "--tail=100".to_string(),
279 ];
280 args.extend(services);
281 compose_for(&workspace)?.run(&args)
282 }
283 Command::Restart {
284 services,
285 build,
286 essential,
287 } => {
288 crate::compose::require_docker()?;
289 regenerate(&workspace)?;
290 let services = resolve_services(&workspace, services, essential)?;
291 let compose = compose_for(&workspace)?;
292 let mut down = vec!["down".to_string()];
293 down.extend(services.clone());
294 compose.run(&down)?;
295 let mut up = vec!["up".to_string(), "-d".to_string()];
296 if build {
297 up.push("--build".into());
298 }
299 up.extend(services);
300 compose.run(&up)
301 }
302 Command::Shell { service } => {
303 crate::compose::require_docker()?;
304 let compose = compose_for(&workspace)?;
305 let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
306 match compose.run(&bash)? {
307 0 => Ok(0),
308 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
310 }
311 }
312 }
313}
314
315fn regenerate(workspace: &Workspace) -> Result<()> {
318 let mut env = Env::load(&workspace.env_path())?;
319 env.derive(&workspace.root);
320 generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
321}
322
323fn compose_for(workspace: &Workspace) -> Result<Compose> {
324 let mut env = Env::load(&workspace.env_path())?;
325 env.derive(&workspace.root);
326 Compose::new(workspace, env)
327}
328
329fn start(workspace: &Workspace, services: Vec<String>, build: bool) -> Result<i32> {
332 crate::compose::require_docker()?;
333 regenerate(workspace)?;
334 let mut args = vec!["up".to_string(), "-d".to_string()];
335 if build {
336 args.push("--build".into());
337 }
338 if services.is_empty() {
339 args.push("--remove-orphans".into());
340 }
341 args.extend(services);
342 compose_for(workspace)?.run(&args)
343}
344
345fn is_service(workspace: &Workspace, name: &str) -> bool {
349 let cached = crate::compose::cached_services(&workspace.root);
350 if !cached.is_empty() {
351 return cached.iter().any(|service| service == name);
352 }
353 compose_for(workspace)
354 .map(|compose| compose.service_names().iter().any(|service| service == name))
355 .unwrap_or(false)
356}
357
358fn resolve_services(
359 workspace: &Workspace,
360 services: Vec<String>,
361 essential: bool,
362) -> Result<Vec<String>> {
363 if !essential {
364 return Ok(services);
365 }
366 if !services.is_empty() {
367 anyhow::bail!("pass service names or --essential, not both");
368 }
369 let listed = workspace.config()?.essential_services();
370 if listed.is_empty() {
371 anyhow::bail!(
372 "no essential services configured — add them under \"[essentials]\" in run.config.toml"
373 );
374 }
375 Ok(listed)
376}
377
378fn print_apps(workspace: &Workspace) -> Result<()> {
379 let config = workspace.config()?;
380 println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
381 println!(
382 "{:<10} {:<24} {:<6}",
383 "backend",
384 config.str_or("BACKEND_STACK", "laravel"),
385 config.port("BACKEND_PORT", 8000)
386 );
387 println!(
388 "{:<10} {:<24} {:<6}",
389 "web",
390 config.str_or("WEB_APP", "web"),
391 config.port("WEB_PORT", 5173)
392 );
393 for (flag, app_key, port_key, role, default_port) in [
394 ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
395 ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
396 ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
397 ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
398 ] {
399 if config.bool_or(flag, false) {
400 println!(
401 "{:<10} {:<24} {:<6}",
402 role,
403 config.str_or(app_key, role),
404 config.port(port_key, default_port)
405 );
406 }
407 }
408 for app in config.extra_apps() {
409 let port_key = format!("{}_PORT", crate::config::key_of(&app));
410 println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
411 }
412 Ok(())
413}
414
415fn print_status() {
416 println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
417 println!("Ported:");
418 for line in [
419 "up [--build] [--essential] [svc...] start the stack",
420 "down [service...] stop it, keep data",
421 "ps / status [service...] service status",
422 "logs [service...] follow logs",
423 "restart [--build] [--essential] [svc...] down then up",
424 "shell / sh [service] shell into a container",
425 "apps / list the apps this workspace runs",
426 "ports host ports",
427 "config the resolved configuration",
428 "explain <compose args> print the docker command, run nothing",
429 "env [KEY] the environment compose is given",
430 "generate write the compose overlays, start nothing",
431 "commands [--raw] every CLI command in a table",
432 "self-update [--verbose] install latest from crates.io, migrate configs",
433 ] {
434 println!(" {line}");
435 }
436 println!("\nHanded to the shell implementation, transparently:");
437 let mut line = String::from(" ");
438 for (name, _) in crate::delegate::PENDING {
439 if line.len() + name.len() + 2 > 76 {
440 println!("{line}");
441 line = String::from(" ");
442 }
443 line.push_str(name);
444 line.push_str(", ");
445 }
446 println!("{}", line.trim_end_matches(", "));
447}