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 version = concat!(env!("CARGO_PKG_VERSION"), " (rust)"),
20 about = "Dockerised local stacks: API, web apps, mobile, database, dashboard"
21)]
22struct Cli {
23 #[command(subcommand)]
24 command: Option<Command>,
25}
26
27#[derive(Subcommand)]
28enum Command {
29 #[command(visible_alias = "run")]
31 Up {
32 services: Vec<String>,
34 #[arg(long)]
36 build: bool,
37 },
38 Down { services: Vec<String> },
40 #[command(visible_alias = "status")]
42 Ps { services: Vec<String> },
43 Logs { services: Vec<String> },
45 Restart {
47 services: Vec<String>,
48 #[arg(long)]
49 build: bool,
50 },
51 #[command(visible_alias = "sh")]
53 Shell {
54 #[arg(default_value = "backend")]
55 service: String,
56 },
57 #[command(visible_alias = "list")]
59 Apps,
60 Ports,
62 Config,
64 Explain { command: Vec<String> },
66 Env { key: Option<String> },
68 Commands,
70 Generate,
72}
73
74const NOT_PORTED: &[(&str, &str)] = &[
76 ("init", "the prompts and the app scan"),
77 ("create", "workspace setup"),
78 ("migrate", "layout conversion"),
79 ("clean", "volume deletion"),
80 ("rebuild", "image rebuild"),
81 ("dash", "dashboard"),
82 ("ios", "simulator launch"),
83 ("android", "emulator launch"),
84 ("device", "device launch"),
85 ("mobile", "metro restart"),
86 ("reload", "metro reload"),
87 ("prebuild", "expo prebuild"),
88 ("desktop", "electron / tauri shell"),
89 ("deploy", "deploy targets"),
90 ("backend", "commands in the API container"),
91 ("artisan", "laravel"),
92 ("composer", "laravel"),
93 ("pnpm", "workspace package manager"),
94 ("seed", "database seeders"),
95 ("fresh", "schema rebuild"),
96 ("services", "compose service table"),
97 ("self-update", "npm / pnpm update"),
98 ("completion", "shell completion"),
99];
100
101pub fn main() {
102 let code = match run() {
103 Ok(code) => code,
104 Err(error) => {
105 eprintln!("error: {error:#}");
106 1
107 }
108 };
109 std::process::exit(code);
110}
111
112fn run() -> Result<i32> {
113 let cli = Cli::parse();
114 let Some(command) = cli.command else {
115 print_status();
116 return Ok(0);
117 };
118
119 if let Command::Commands = command {
120 print_status();
121 return Ok(0);
122 }
123
124 let workspace = Workspace::find(&std::env::current_dir()?)?;
125
126 match command {
127 Command::Commands => unreachable!("handled above"),
128 Command::Config => {
129 print!("{}", workspace.config()?.to_json());
130 Ok(0)
131 }
132 Command::Apps => {
133 print_apps(&workspace)?;
134 Ok(0)
135 }
136 Command::Ports => {
137 let config = workspace.config()?;
138 for key in config.keys().filter(|key| key.ends_with("_PORT")) {
139 println!("{:<24} {}", key, config.port(key, 0));
140 }
141 Ok(0)
142 }
143 Command::Generate => {
144 regenerate(&workspace)?;
145 println!("wrote the overlays in {}", workspace.run_dir.display());
146 Ok(0)
147 }
148 Command::Env { key } => {
149 let mut env = Env::load(&workspace.env_path())?;
150 env.derive(&workspace.root);
151 match key {
152 Some(key) => println!("{}", env.get(&key).unwrap_or("")),
153 None => {
154 for (key, value) in env.iter() {
155 println!("{key}={value}");
156 }
157 }
158 }
159 Ok(0)
160 }
161 Command::Explain { command } => {
162 let compose = compose_for(&workspace)?;
163 println!("docker {}", compose.args(&command).join(" "));
164 Ok(0)
165 }
166 Command::Up { services, build } => {
167 crate::compose::require_docker()?;
168 regenerate(&workspace)?;
169 let mut args = vec!["up".to_string(), "-d".to_string()];
170 if build {
171 args.push("--build".into());
172 }
173 if services.is_empty() {
174 args.push("--remove-orphans".into());
175 }
176 args.extend(services);
177 compose_for(&workspace)?.run(&args)
178 }
179 Command::Down { services } => {
180 crate::compose::require_docker()?;
181 let mut args = vec!["down".to_string()];
182 args.extend(services);
183 compose_for(&workspace)?.run(&args)
184 }
185 Command::Ps { services } => {
186 crate::compose::require_docker()?;
187 let mut args = vec!["ps".to_string()];
188 args.extend(services);
189 compose_for(&workspace)?.run(&args)
190 }
191 Command::Logs { services } => {
192 crate::compose::require_docker()?;
193 let mut args = vec![
194 "logs".to_string(),
195 "-f".to_string(),
196 "--tail=100".to_string(),
197 ];
198 args.extend(services);
199 compose_for(&workspace)?.run(&args)
200 }
201 Command::Restart { services, build } => {
202 crate::compose::require_docker()?;
203 regenerate(&workspace)?;
204 let compose = compose_for(&workspace)?;
205 let mut down = vec!["down".to_string()];
206 down.extend(services.clone());
207 compose.run(&down)?;
208 let mut up = vec!["up".to_string(), "-d".to_string()];
209 if build {
210 up.push("--build".into());
211 }
212 up.extend(services);
213 compose.run(&up)
214 }
215 Command::Shell { service } => {
216 crate::compose::require_docker()?;
217 let compose = compose_for(&workspace)?;
218 let bash = vec!["exec".to_string(), service.clone(), "bash".to_string()];
219 match compose.run(&bash)? {
220 0 => Ok(0),
221 _ => compose.run(&["exec".to_string(), service, "sh".to_string()]),
223 }
224 }
225 }
226}
227
228fn regenerate(workspace: &Workspace) -> Result<()> {
231 let mut env = Env::load(&workspace.env_path())?;
232 env.derive(&workspace.root);
233 generate::all(&workspace.run_dir, &env, &crate::compose::package_dir()?)
234}
235
236fn compose_for(workspace: &Workspace) -> Result<Compose> {
237 let mut env = Env::load(&workspace.env_path())?;
238 env.derive(&workspace.root);
239 Compose::new(workspace, env)
240}
241
242fn print_apps(workspace: &Workspace) -> Result<()> {
243 let config = workspace.config()?;
244 println!("{:<10} {:<24} {:<6}", "ROLE", "PACKAGE", "PORT");
245 println!(
246 "{:<10} {:<24} {:<6}",
247 "backend",
248 config.str_or("BACKEND_STACK", "laravel"),
249 config.port("BACKEND_PORT", 8000)
250 );
251 println!(
252 "{:<10} {:<24} {:<6}",
253 "web",
254 config.str_or("WEB_APP", "web"),
255 config.port("WEB_PORT", 5173)
256 );
257 for (flag, app_key, port_key, role, default_port) in [
258 ("RUN_ADMIN", "ADMIN_APP", "ADMIN_PORT", "admin", 5174),
259 ("RUN_LANDING", "LANDING_APP", "LANDING_PORT", "landing", 5175),
260 ("RUN_MOBILE", "MOBILE_APP", "MOBILE_CLIENT_PORT", "mobile", 8081),
261 ("RUN_DESKTOP", "DESKTOP_APP", "DESKTOP_PORT", "desktop", 5176),
262 ] {
263 if config.bool_or(flag, false) {
264 println!(
265 "{:<10} {:<24} {:<6}",
266 role,
267 config.str_or(app_key, role),
268 config.port(port_key, default_port)
269 );
270 }
271 }
272 for app in config.extra_apps() {
273 let port_key = format!("{}_PORT", crate::config::key_of(&app));
274 println!("{:<10} {:<24} {:<6}", "extra", app, config.port(&port_key, 0));
275 }
276 Ok(())
277}
278
279fn print_status() {
280 println!("run-stack {} (rust port in progress)\n", env!("CARGO_PKG_VERSION"));
281 println!("Ported:");
282 for line in [
283 "up [--build] [service...] start the stack",
284 "down [service...] stop it, keep data",
285 "ps / status [service...] service status",
286 "logs [service...] follow logs",
287 "restart [--build] [svc...] down then up",
288 "shell / sh [service] shell into a container",
289 "apps / list the apps this workspace runs",
290 "ports host ports",
291 "config the resolved configuration",
292 "explain <compose args> print the docker command, run nothing",
293 "env [KEY] the environment compose is given",
294 "generate write the compose overlays, start nothing",
295 ] {
296 println!(" {line}");
297 }
298 println!("\nStill the shell version — use `rst <command>`:");
299 let mut line = String::from(" ");
300 for (name, _) in NOT_PORTED {
301 if line.len() + name.len() + 2 > 76 {
302 println!("{line}");
303 line = String::from(" ");
304 }
305 line.push_str(name);
306 line.push_str(", ");
307 }
308 println!("{}", line.trim_end_matches(", "));
309}