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}
98
99#[allow(dead_code)]
100const UNUSED_NOT_PORTED: &[(&str, &str)] = &[
101 ("init", "the prompts and the app scan"),
102 ("create", "workspace setup"),
103 ("migrate", "layout conversion"),
104 ("clean", "volume deletion"),
105 ("rebuild", "image rebuild"),
106 ("dash", "dashboard"),
107 ("ios", "simulator launch"),
108 ("android", "emulator launch"),
109 ("device", "device launch"),
110 ("mobile", "metro restart"),
111 ("reload", "metro reload"),
112 ("prebuild", "expo prebuild"),
113 ("desktop", "electron / tauri shell"),
114 ("deploy", "deploy targets"),
115 ("backend", "commands in the API container"),
116 ("artisan", "laravel"),
117 ("composer", "laravel"),
118 ("pnpm", "workspace package manager"),
119 ("seed", "database seeders"),
120 ("fresh", "schema rebuild"),
121 ("services", "compose service table"),
122 ("completion", "shell completion"),
123];
124
125pub fn main() {
126 let code = match run() {
127 Ok(code) => code,
128 Err(error) => {
129 eprintln!("error: {error:#}");
130 1
131 }
132 };
133 std::process::exit(code);
134}
135
136fn run() -> Result<i32> {
137 let cli = Cli::parse();
138 let Some(command) = cli.command else {
139 print_status();
140 return Ok(0);
141 };
142
143 if let Command::Ported = command {
144 print_status();
145 return Ok(0);
146 }
147
148 if let Command::Commands { raw } = command {
149 return crate::commands::run(raw);
150 }
151
152 if let Command::SelfUpdate { verbose } = command {
153 return crate::self_update::run(verbose);
154 }
155
156 if let Command::Delegated(argv) = &command {
159 let (name, rest) = argv.split_first().expect("clap yields a name");
160 let workspace = Workspace::find(&std::env::current_dir()?).ok();
161 return crate::delegate::run(name, rest, workspace.as_ref());
162 }
163
164 let workspace = Workspace::find(&std::env::current_dir()?)?;
165
166 match command {
167 Command::Ported
168 | Command::Commands { .. }
169 | Command::Delegated(_)
170 | Command::SelfUpdate { .. } => unreachable!("handled above"),
171 Command::Config => {
172 print!("{}", workspace.config()?.to_json());
173 Ok(0)
174 }
175 Command::Apps => {
176 print_apps(&workspace)?;
177 Ok(0)
178 }
179 Command::Ports => {
180 let config = workspace.config()?;
181 for key in config.keys().filter(|key| key.ends_with("_PORT")) {
182 println!("{:<24} {}", key, config.port(key, 0));
183 }
184 Ok(0)
185 }
186 Command::Generate => {
187 regenerate(&workspace)?;
188 println!("wrote the overlays in {}", workspace.run_dir.display());
189 Ok(0)
190 }
191 Command::Env { key } => {
192 let mut env = Env::load(&workspace.env_path())?;
193 env.derive(&workspace.root);
194 match key {
195 Some(key) => println!("{}", env.get(&key).unwrap_or("")),
196 None => {
197 for (key, value) in env.iter() {
198 println!("{key}={value}");
199 }
200 }
201 }
202 Ok(0)
203 }
204 Command::Explain { command } => {
205 let compose = compose_for(&workspace)?;
206 println!("docker {}", compose.args(&command).join(" "));
207 Ok(0)
208 }
209 Command::Up {
210 services,
211 build,
212 essential,
213 } => {
214 crate::compose::require_docker()?;
215 regenerate(&workspace)?;
216 let services = resolve_services(&workspace, services, essential)?;
217 let mut args = vec!["up".to_string(), "-d".to_string()];
218 if build {
219 args.push("--build".into());
220 }
221 if services.is_empty() {
222 args.push("--remove-orphans".into());
223 }
224 args.extend(services);
225 compose_for(&workspace)?.run(&args)
226 }
227 Command::Down { services } => {
228 crate::compose::require_docker()?;
229 let mut args = vec!["down".to_string()];
230 args.extend(services);
231 compose_for(&workspace)?.run(&args)
232 }
233 Command::Ps { services } => {
234 crate::compose::require_docker()?;
235 let mut args = vec!["ps".to_string()];
236 args.extend(services);
237 compose_for(&workspace)?.run(&args)
238 }
239 Command::Logs { services } => {
240 crate::compose::require_docker()?;
241 let mut args = vec![
242 "logs".to_string(),
243 "-f".to_string(),
244 "--tail=100".to_string(),
245 ];
246 args.extend(services);
247 compose_for(&workspace)?.run(&args)
248 }
249 Command::Restart {
250 services,
251 build,
252 essential,
253 } => {
254 crate::compose::require_docker()?;
255 regenerate(&workspace)?;
256 let services = resolve_services(&workspace, services, essential)?;
257 let compose = compose_for(&workspace)?;
258 let mut down = vec!["down".to_string()];
259 down.extend(services.clone());
260 compose.run(&down)?;
261 let mut up = vec!["up".to_string(), "-d".to_string()];
262 if build {
263 up.push("--build".into());
264 }
265 up.extend(services);
266 compose.run(&up)
267 }
268 Command::Shell { service } => {
269 crate::compose::require_docker()?;
270 let compose = compose_for(&workspace)?;
271 let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
272 match compose.run(&bash)? {
273 0 => Ok(0),
274 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
276 }
277 }
278 }
279}
280
281fn regenerate(workspace: &Workspace) -> Result<()> {
284 let mut env = Env::load(&workspace.env_path())?;
285 env.derive(&workspace.root);
286 generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
287}
288
289fn compose_for(workspace: &Workspace) -> Result<Compose> {
290 let mut env = Env::load(&workspace.env_path())?;
291 env.derive(&workspace.root);
292 Compose::new(workspace, env)
293}
294
295fn resolve_services(
296 workspace: &Workspace,
297 services: Vec<String>,
298 essential: bool,
299) -> Result<Vec<String>> {
300 if !essential {
301 return Ok(services);
302 }
303 if !services.is_empty() {
304 anyhow::bail!("pass service names or --essential, not both");
305 }
306 let listed = workspace.config()?.essential_services();
307 if listed.is_empty() {
308 anyhow::bail!(
309 "no essential services configured — add them under \"essential\" in run.config.json"
310 );
311 }
312 Ok(listed)
313}
314
315fn print_apps(workspace: &Workspace) -> Result<()> {
316 let config = workspace.config()?;
317 println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
318 println!(
319 "{:<10} {:<24} {:<6}",
320 "backend",
321 config.str_or("BACKEND_STACK", "laravel"),
322 config.port("BACKEND_PORT", 8000)
323 );
324 println!(
325 "{:<10} {:<24} {:<6}",
326 "web",
327 config.str_or("WEB_APP", "web"),
328 config.port("WEB_PORT", 5173)
329 );
330 for (flag, app_key, port_key, role, default_port) in [
331 ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
332 ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
333 ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
334 ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
335 ] {
336 if config.bool_or(flag, false) {
337 println!(
338 "{:<10} {:<24} {:<6}",
339 role,
340 config.str_or(app_key, role),
341 config.port(port_key, default_port)
342 );
343 }
344 }
345 for app in config.extra_apps() {
346 let port_key = format!("{}_PORT", crate::config::key_of(&app));
347 println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
348 }
349 Ok(())
350}
351
352fn print_status() {
353 println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
354 println!("Ported:");
355 for line in [
356 "up [--build] [--essential] [svc...] start the stack",
357 "down [service...] stop it, keep data",
358 "ps / status [service...] service status",
359 "logs [service...] follow logs",
360 "restart [--build] [--essential] [svc...] down then up",
361 "shell / sh [service] shell into a container",
362 "apps / list the apps this workspace runs",
363 "ports host ports",
364 "config the resolved configuration",
365 "explain <compose args> print the docker command, run nothing",
366 "env [KEY] the environment compose is given",
367 "generate write the compose overlays, start nothing",
368 "commands [--raw] every CLI command in a table",
369 "self-update [--verbose] install latest from crates.io, migrate configs",
370 ] {
371 println!(" {line}");
372 }
373 println!("\nHanded to the shell implementation, transparently:");
374 let mut line = String::from(" ");
375 for (name, _) in crate::delegate::PENDING {
376 if line.len() + name.len() + 2 > 76 {
377 println!("{line}");
378 line = String::from(" ");
379 }
380 line.push_str(name);
381 line.push_str(", ");
382 }
383 println!("{}", line.trim_end_matches(", "));
384}