1use std::fs;
10use std::path::Path;
11
12use anyhow::{Context, Result};
13
14use crate::config::key_of;
15use crate::env::Env;
16
17const FRONTEND_SERVICES: &[&str] = &[
19 "frontend-deps",
20 "frontend-packages",
21 "frontend-sync",
22 "web",
23 "admin",
24 "landing",
25 "mobile-deps",
26 "mobile-packages",
27 "mobile-client",
28];
29
30const METRO_SERVICES: &[&str] = &["mobile-deps", "mobile-packages", "mobile-client"];
31
32pub fn all(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
33 packages(run_dir, env)?;
34 apps(run_dir, env, package_dir)?;
35 resources(run_dir, env)?;
36 Ok(())
37}
38
39fn package_dirs(frontend: &Path) -> Vec<String> {
41 let mut found = Vec::new();
42 for root in ["apps", "packages"] {
43 let Ok(entries) = fs::read_dir(frontend.join(root)) else {
44 continue;
45 };
46 for entry in entries.flatten() {
47 if entry.path().join("package.json").is_file() {
48 found.push(format!(
49 "{root}/{}",
50 entry.file_name().to_string_lossy()
51 ));
52 }
53 }
54 }
55 found.sort();
56 found
57}
58
59fn volume_name(dir: &str) -> String {
61 format!(
62 "fe_nm_{}",
63 dir.replace(['/', '-'], "_")
64 )
65}
66
67fn extra_apps(env: &Env) -> Vec<String> {
68 env.get_or("EXTRA_APPS", "")
69 .split_whitespace()
70 .map(|name| name.split(':').next().unwrap_or(name).to_string())
71 .collect()
72}
73
74fn mobile_enabled(env: &Env) -> bool {
75 env.is_true("RUN_MOBILE", false)
76}
77
78fn packages(run_dir: &Path, env: &Env) -> Result<()> {
80 let frontend = Path::new(env.get_or("FRONTEND_DIR", ""));
81 let dirs = package_dirs(frontend);
82 let stack = env.get_or("BACKEND_STACK", "laravel").to_string();
83 let backend_in_frontend =
84 env.get_or("BACKEND_DIR", "").trim_end_matches('/') == env.get_or("FRONTEND_DIR", "").trim_end_matches('/');
85 let subdir = env.get_or("BACKEND_SUBDIR", "").trim_end_matches('/').to_string();
86
87 let mut services: Vec<String> = FRONTEND_SERVICES.iter().map(|s| s.to_string()).collect();
88 services.extend(extra_apps(env));
89
90 let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
91
92 if stack == "node" {
95 for service in ["backend", "queue", "scheduler"] {
96 out.push_str(&format!(" {service}:\n volumes:\n"));
97 out.push_str(" - pnpm_store:/pnpm-store\n");
98 out.push_str(" - fe_nm_root:/app/node_modules\n");
99 if backend_in_frontend {
100 for dir in &dirs {
101 out.push_str(&format!(" - {}:/app/{dir}/node_modules\n", volume_name(dir)));
102 }
103 out.push_str(" depends_on:\n frontend-deps:\n condition: service_completed_successfully\n");
104 } else if !subdir.is_empty() {
105 out.push_str(&format!(" - be_node_modules:/app/{subdir}/node_modules\n"));
106 }
107 }
108 }
109
110 for service in &services {
111 out.push_str(&format!(" {service}:\n"));
112 if !mobile_enabled(env) && METRO_SERVICES.contains(&service.as_str()) {
116 out.push_str(" profiles: [\"__no_mobile\"]\n");
117 }
118 if dirs.is_empty() {
119 continue;
120 }
121 out.push_str(" volumes:\n");
122 for dir in &dirs {
123 out.push_str(&format!(" - {}:/app/{dir}/node_modules\n", volume_name(dir)));
124 }
125 }
126
127 out.push_str("volumes:\n");
128 if stack == "node" && !backend_in_frontend && !subdir.is_empty() {
129 out.push_str(" be_node_modules:\n");
130 }
131 for dir in &dirs {
132 out.push_str(&format!(" {}:\n", volume_name(dir)));
133 }
134
135 write(run_dir.join("docker-compose.packages.yml"), &out)
136}
137
138fn apps(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
144 let apps = extra_apps(env);
145 let target = run_dir.join("docker-compose.extra.yml");
146 if apps.is_empty() {
147 let _ = fs::remove_file(&target);
148 return Ok(());
149 }
150
151 let base = fs::read_to_string(package_dir.join("docker-compose.yml"))
152 .with_context(|| format!("reading {}", package_dir.join("docker-compose.yml").display()))?;
153 let anchors: String = base
154 .lines()
155 .skip_while(|line| !line.starts_with("x-"))
156 .take_while(|line| !line.starts_with("services:"))
157 .map(|line| format!("{line}\n"))
158 .collect();
159
160 let frontend = Path::new(env.get_or("FRONTEND_DIR", "")).to_path_buf();
161 let mut out = String::from("# Generated by run-stack — do not edit.\n");
162 out.push_str("# One service per EXTRA_APPS entry; edit run.config.json and re-run up.\n");
163 out.push_str(&anchors);
164 out.push_str("services:\n");
165
166 out.push_str(" frontend-deps:\n environment:\n <<: *frontend-env\n");
169 out.push_str(&format!(
170 " EXTRA_DEPS_APPS: \"${{EXTRA_DEPS_APPS:-}} {} \"\n\n",
171 apps.join(" ")
172 ));
173
174 let mut metro = Vec::new();
175 out.push_str(" dashboard:\n environment:\n");
176 out.push_str(&format!(" EXTRA_APPS: \"{}\"\n", env.get_or("EXTRA_APPS", "")));
177 for app in &apps {
178 out.push_str(&format!(
179 " {}_PORT: \"{}\"\n",
180 key_of(app),
181 port_of(env, app, &frontend)
182 ));
183 if is_metro_app(&frontend, app) {
184 metro.push(app.clone());
185 }
186 }
187 out.push_str(&format!(" EXTRA_APPS_MOBILE: \"{}\"\n\n", metro.join(" ")));
188
189 for app in &apps {
190 let key = key_of(app);
191 let port = port_of(env, app, &frontend);
192 out.push_str(&format!(" {app}:\n <<: *frontend-service\n environment:\n <<: *frontend-env\n"));
193 out.push_str(&format!(" APP_CMD: ${{{key}_CMD:-}}\n"));
194 if metro.contains(app) {
195 out.push_str(&format!(" MOBILE_APP: \"{app}\"\n"));
197 out.push_str(&format!(" MOBILE_STACK: ${{{key}_STACK:-auto}}\n"));
198 out.push_str(" EXPO_NO_TELEMETRY: \"1\"\n");
199 out.push_str(" EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0\n");
200 out.push_str(" REACT_NATIVE_PACKAGER_HOSTNAME: ${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}\n");
201 out.push_str(&format!(" RCT_METRO_PORT: \"{port}\"\n"));
202 out.push_str(" EXPO_PUBLIC_API_BASE_URL: ${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}\n");
203 out.push_str(&format!(" command: [\"metro\", \"{app}\", \"{port}\"]\n"));
204 } else {
205 out.push_str(&format!(" command: [\"app\", \"{app}\", \"{port}\"]\n"));
206 }
207 out.push_str(&format!(" ports:\n - \"{port}:{port}\"\n\n"));
208 }
209
210 write(target, &out)
211}
212
213fn is_metro_app(frontend: &Path, app: &str) -> bool {
216 let Some(dir) = app_dir(frontend, app) else {
217 return false;
218 };
219 let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
220 return false;
221 };
222 let Ok(package) = serde_json::from_str::<serde_json::Value>(&text) else {
223 return false;
224 };
225 ["dependencies", "devDependencies"].iter().any(|section| {
226 package[section]
227 .as_object()
228 .is_some_and(|deps| {
229 deps.keys().any(|name| {
230 name == "expo"
231 || name.starts_with("expo-")
232 || name == "react-native"
233 || name.starts_with("react-native-")
234 })
235 })
236 })
237}
238
239fn app_dir(frontend: &Path, app: &str) -> Option<std::path::PathBuf> {
240 for root in ["apps", "packages"] {
241 let direct = frontend.join(root).join(app);
242 if direct.join("package.json").is_file() {
243 return Some(direct);
244 }
245 if let Ok(entries) = fs::read_dir(frontend.join(root)) {
247 for entry in entries.flatten() {
248 let manifest = entry.path().join("package.json");
249 let Ok(text) = fs::read_to_string(&manifest) else {
250 continue;
251 };
252 let named = serde_json::from_str::<serde_json::Value>(&text)
253 .ok()
254 .and_then(|package| package["name"].as_str().map(str::to_string));
255 if named.as_deref() == Some(app) {
256 return Some(entry.path());
257 }
258 }
259 }
260 }
261 None
262}
263
264fn port_of(env: &Env, app: &str, frontend: &Path) -> u16 {
265 let key = format!("{}_PORT", key_of(app));
266 if let Some(port) = env.get(&key).and_then(|value| value.trim().parse().ok()) {
267 return port;
268 }
269 if is_metro_app(frontend, app) {
270 8082
271 } else {
272 5180
273 }
274}
275
276fn resources(run_dir: &Path, env: &Env) -> Result<()> {
278 let target = run_dir.join("docker-compose.resources.yml");
279 let pick = |specific: &str, global: &str| -> String {
280 let value = env.get_or(specific, "");
281 if !value.is_empty() {
282 return value.to_string();
283 }
284 env.get_or(global, "").to_string()
285 };
286 let frontend = (
287 pick("FRONTEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
288 pick("FRONTEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
289 );
290 let backend = (
291 pick("BACKEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
292 pick("BACKEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
293 );
294 let postgres = (
295 pick("POSTGRES_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
296 pick("POSTGRES_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
297 );
298 let mysql = (
299 pick("MYSQL_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
300 pick("MYSQL_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
301 );
302
303 let nothing_set = [&frontend, &backend, &postgres, &mysql]
304 .iter()
305 .all(|(memory, cpu)| memory.is_empty() && cpu.is_empty());
306 if nothing_set {
307 let _ = fs::remove_file(&target);
310 return Ok(());
311 }
312
313 let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
314 let mut emit = |service: &str, limits: &(String, String)| {
315 if limits.0.is_empty() && limits.1.is_empty() {
316 return;
317 }
318 out.push_str(&format!(" {service}:\n"));
319 if !limits.0.is_empty() {
320 out.push_str(&format!(" mem_limit: {}\n", limits.0));
321 }
322 if !limits.1.is_empty() {
323 out.push_str(&format!(" cpus: {}\n", limits.1));
324 }
325 };
326 for service in FRONTEND_SERVICES.iter().chain(["desktop"].iter()) {
327 emit(service, &frontend);
328 }
329 for app in extra_apps(env) {
330 emit(&app, &frontend);
331 }
332 for service in ["backend", "queue", "scheduler"] {
333 emit(service, &backend);
334 }
335 emit("postgres", &postgres);
336 emit("mysql", &mysql);
337
338 write(target, &out)
339}
340
341fn write(path: std::path::PathBuf, contents: &str) -> Result<()> {
342 if let Some(parent) = path.parent() {
343 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
344 }
345 fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
346}