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 root_apps_file(run_dir, env)?;
36 resources(run_dir, env)?;
37 Ok(())
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct RootApp {
48 pub name: String,
49 pub dir: String,
50}
51
52pub fn root_apps(env: &Env) -> Vec<RootApp> {
55 env.get_or("ROOT_APPS", "")
56 .split_whitespace()
57 .filter_map(|entry| {
58 let (name, dir) = match entry.split_once(':') {
59 Some((name, dir)) if !name.is_empty() && !dir.is_empty() => (name, dir),
60 _ => (entry, entry),
61 };
62 if name.is_empty() {
63 return None;
64 }
65 Some(RootApp {
66 name: name.to_string(),
67 dir: dir.trim_end_matches('/').to_string(),
68 })
69 })
70 .collect()
71}
72
73fn root_app_port(env: &Env, app: &RootApp, index: usize) -> u16 {
74 let key = format!("{}_PORT", key_of(&app.name));
75 env.get(&key)
76 .and_then(|value| value.trim().parse().ok())
77 .unwrap_or(4500 + index as u16)
78}
79
80fn root_apps_file(run_dir: &Path, env: &Env) -> Result<()> {
81 let apps = root_apps(env);
82 let target = run_dir.join("docker-compose.root-apps.yml");
83 if apps.is_empty() {
84 let _ = fs::remove_file(&target);
85 return Ok(());
86 }
87 let root = run_dir.parent().unwrap_or(run_dir);
91 write(target, &render_root_apps(env, root, &apps))
92}
93
94fn render_root_apps(env: &Env, root: &Path, apps: &[RootApp]) -> String {
95 let mut out = String::from("# Generated by run-stack - do not edit.\n");
96 out.push_str("# One service per ROOT_APPS entry: an app beside the frontend repo,\n");
97 out.push_str("# mounted on its own and started with its own command.\n");
98 out.push_str("services:\n");
99
100 for (index, app) in apps.iter().enumerate() {
101 let key = key_of(&app.name);
102 let port = root_app_port(env, app, index);
103 let volume = root_volume_name(&app.name);
104
105 out.push_str(&format!(" {}:\n", app.name));
106 out.push_str(" build:\n context: ./docker/frontend\n");
107 out.push_str(" image: ${COMPOSE_PROJECT_NAME:-myapp}/frontend:local\n");
108 out.push_str(" working_dir: /app\n");
109 out.push_str(" environment:\n");
110 out.push_str(&format!(" APP_CMD: ${{{key}_CMD:-npm run dev}}\n"));
111 out.push_str(&format!(" PORT: \"{port}\"\n"));
112 out.push_str(" volumes:\n");
113 out.push_str(&format!(" - {}/{}:/app\n", root.display(), app.dir));
114 out.push_str(" - ./docker/frontend/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro\n");
117 out.push_str(" - ./docker/frontend/mobile-stack.sh:/usr/local/bin/mobile-stack.sh:ro\n");
118 out.push_str(" - pnpm_store:/pnpm-store\n");
119 out.push_str(&format!(" - {volume}:/app/node_modules\n"));
122 out.push_str(&format!(
123 " command: [\"root-app\", \"{}\", \"{port}\"]\n",
124 app.name
125 ));
126 out.push_str(&format!(" ports:\n - \"{port}:{port}\"\n"));
127 out.push_str(" restart: unless-stopped\n\n");
128 }
129
130 out.push_str("volumes:\n");
131 for app in apps {
132 out.push_str(&format!(" {}:\n", root_volume_name(&app.name)));
133 }
134 out
135}
136
137fn root_volume_name(name: &str) -> String {
138 format!("root_nm_{}", name.replace(['/', '-'], "_"))
139}
140
141fn package_dirs(frontend: &Path) -> Vec<String> {
143 let mut found = Vec::new();
144 for root in ["apps", "packages"] {
145 let Ok(entries) = fs::read_dir(frontend.join(root)) else {
146 continue;
147 };
148 for entry in entries.flatten() {
149 if entry.path().join("package.json").is_file() {
150 found.push(format!(
151 "{root}/{}",
152 entry.file_name().to_string_lossy()
153 ));
154 }
155 }
156 }
157 found.sort();
158 found
159}
160
161fn volume_name(dir: &str) -> String {
163 format!(
164 "fe_nm_{}",
165 dir.replace(['/', '-'], "_")
166 )
167}
168
169fn extra_apps(env: &Env) -> Vec<String> {
170 env.get_or("EXTRA_APPS", "")
171 .split_whitespace()
172 .map(|name| name.split(':').next().unwrap_or(name).to_string())
173 .collect()
174}
175
176fn mobile_enabled(env: &Env) -> bool {
177 env.is_true("RUN_MOBILE", false)
178}
179
180fn packages(run_dir: &Path, env: &Env) -> Result<()> {
182 let frontend = Path::new(env.get_or("FRONTEND_DIR", ""));
183 let dirs = package_dirs(frontend);
184 let stack = env.get_or("BACKEND_STACK", "laravel").to_string();
185 let backend_in_frontend =
186 env.get_or("BACKEND_DIR", "").trim_end_matches('/') == env.get_or("FRONTEND_DIR", "").trim_end_matches('/');
187 let subdir = env.get_or("BACKEND_SUBDIR", "").trim_end_matches('/').to_string();
188
189 let mut services: Vec<String> = FRONTEND_SERVICES.iter().map(|s| s.to_string()).collect();
190 services.extend(extra_apps(env));
191
192 let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
193
194 if stack == "node" {
197 for service in ["backend", "queue", "scheduler"] {
198 out.push_str(&format!(" {service}:\n volumes:\n"));
199 out.push_str(" - pnpm_store:/pnpm-store\n");
200 out.push_str(" - fe_nm_root:/app/node_modules\n");
201 if backend_in_frontend {
202 for dir in &dirs {
203 out.push_str(&format!(" - {}:/app/{dir}/node_modules\n", volume_name(dir)));
204 }
205 out.push_str(" depends_on:\n frontend-deps:\n condition: service_completed_successfully\n");
206 } else if !subdir.is_empty() {
207 out.push_str(&format!(" - be_node_modules:/app/{subdir}/node_modules\n"));
208 }
209 }
210 }
211
212 for service in &services {
213 out.push_str(&format!(" {service}:\n"));
214 if !mobile_enabled(env) && METRO_SERVICES.contains(&service.as_str()) {
218 out.push_str(" profiles: [\"__no_mobile\"]\n");
219 }
220 if dirs.is_empty() {
221 continue;
222 }
223 out.push_str(" volumes:\n");
224 for dir in &dirs {
225 out.push_str(&format!(" - {}:/app/{dir}/node_modules\n", volume_name(dir)));
226 }
227 }
228
229 out.push_str("volumes:\n");
230 if stack == "node" && !backend_in_frontend && !subdir.is_empty() {
231 out.push_str(" be_node_modules:\n");
232 }
233 for dir in &dirs {
234 out.push_str(&format!(" {}:\n", volume_name(dir)));
235 }
236
237 write(run_dir.join("docker-compose.packages.yml"), &out)
238}
239
240fn apps(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
246 let apps = extra_apps(env);
247 let target = run_dir.join("docker-compose.extra.yml");
248 if apps.is_empty() {
249 let _ = fs::remove_file(&target);
250 return Ok(());
251 }
252
253 let base = fs::read_to_string(package_dir.join("docker-compose.yml"))
254 .with_context(|| format!("reading {}", package_dir.join("docker-compose.yml").display()))?;
255 let anchors: String = base
256 .lines()
257 .skip_while(|line| !line.starts_with("x-"))
258 .take_while(|line| !line.starts_with("services:"))
259 .map(|line| format!("{line}\n"))
260 .collect();
261
262 let frontend = Path::new(env.get_or("FRONTEND_DIR", "")).to_path_buf();
263 let mut out = String::from("# Generated by run-stack — do not edit.\n");
264 out.push_str("# One service per EXTRA_APPS entry; edit run.config.toml and re-run up.\n");
265 out.push_str(&anchors);
266 out.push_str("services:\n");
267
268 out.push_str(" frontend-deps:\n environment:\n <<: *frontend-env\n");
271 out.push_str(&format!(
272 " EXTRA_DEPS_APPS: \"${{EXTRA_DEPS_APPS:-}} {} \"\n\n",
273 apps.join(" ")
274 ));
275
276 let mut metro = Vec::new();
277 out.push_str(" dashboard:\n environment:\n");
278 out.push_str(&format!(" EXTRA_APPS: \"{}\"\n", env.get_or("EXTRA_APPS", "")));
279 for app in &apps {
280 out.push_str(&format!(
281 " {}_PORT: \"{}\"\n",
282 key_of(app),
283 port_of(env, app, &frontend)
284 ));
285 if is_metro_app(&frontend, app) {
286 metro.push(app.clone());
287 }
288 }
289 out.push_str(&format!(" EXTRA_APPS_MOBILE: \"{}\"\n\n", metro.join(" ")));
290
291 for app in &apps {
292 let key = key_of(app);
293 let port = port_of(env, app, &frontend);
294 out.push_str(&format!(" {app}:\n <<: *frontend-service\n environment:\n <<: *frontend-env\n"));
295 out.push_str(&format!(" APP_CMD: ${{{key}_CMD:-}}\n"));
296 if metro.contains(app) {
297 out.push_str(&format!(" MOBILE_APP: \"{app}\"\n"));
299 out.push_str(&format!(" MOBILE_STACK: ${{{key}_STACK:-auto}}\n"));
300 out.push_str(" EXPO_NO_TELEMETRY: \"1\"\n");
301 out.push_str(" EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0\n");
302 out.push_str(" REACT_NATIVE_PACKAGER_HOSTNAME: ${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}\n");
303 out.push_str(&format!(" RCT_METRO_PORT: \"{port}\"\n"));
304 out.push_str(" EXPO_PUBLIC_API_BASE_URL: ${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}\n");
305 out.push_str(&format!(" command: [\"metro\", \"{app}\", \"{port}\"]\n"));
306 } else {
307 out.push_str(&format!(" command: [\"app\", \"{app}\", \"{port}\"]\n"));
308 }
309 out.push_str(&format!(" ports:\n - \"{port}:{port}\"\n\n"));
310 }
311
312 write(target, &out)
313}
314
315fn is_metro_app(frontend: &Path, app: &str) -> bool {
318 let Some(dir) = app_dir(frontend, app) else {
319 return false;
320 };
321 let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
322 return false;
323 };
324 let Ok(package) = serde_json::from_str::<serde_json::Value>(&text) else {
325 return false;
326 };
327 ["dependencies", "devDependencies"].iter().any(|section| {
328 package[section]
329 .as_object()
330 .is_some_and(|deps| {
331 deps.keys().any(|name| {
332 name == "expo"
333 || name.starts_with("expo-")
334 || name == "react-native"
335 || name.starts_with("react-native-")
336 })
337 })
338 })
339}
340
341fn app_dir(frontend: &Path, app: &str) -> Option<std::path::PathBuf> {
342 for root in ["apps", "packages"] {
343 let direct = frontend.join(root).join(app);
344 if direct.join("package.json").is_file() {
345 return Some(direct);
346 }
347 if let Ok(entries) = fs::read_dir(frontend.join(root)) {
349 for entry in entries.flatten() {
350 let manifest = entry.path().join("package.json");
351 let Ok(text) = fs::read_to_string(&manifest) else {
352 continue;
353 };
354 let named = serde_json::from_str::<serde_json::Value>(&text)
355 .ok()
356 .and_then(|package| package["name"].as_str().map(str::to_string));
357 if named.as_deref() == Some(app) {
358 return Some(entry.path());
359 }
360 }
361 }
362 }
363 None
364}
365
366fn port_of(env: &Env, app: &str, frontend: &Path) -> u16 {
367 let key = format!("{}_PORT", key_of(app));
368 if let Some(port) = env.get(&key).and_then(|value| value.trim().parse().ok()) {
369 return port;
370 }
371 if is_metro_app(frontend, app) {
372 8082
373 } else {
374 5180
375 }
376}
377
378fn resources(run_dir: &Path, env: &Env) -> Result<()> {
380 let target = run_dir.join("docker-compose.resources.yml");
381 let pick = |specific: &str, global: &str| -> String {
382 let value = env.get_or(specific, "");
383 if !value.is_empty() {
384 return value.to_string();
385 }
386 env.get_or(global, "").to_string()
387 };
388 let frontend = (
389 pick("FRONTEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
390 pick("FRONTEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
391 );
392 let backend = (
393 pick("BACKEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
394 pick("BACKEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
395 );
396 let postgres = (
397 pick("POSTGRES_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
398 pick("POSTGRES_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
399 );
400 let mysql = (
401 pick("MYSQL_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
402 pick("MYSQL_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
403 );
404
405 let nothing_set = [&frontend, &backend, &postgres, &mysql]
406 .iter()
407 .all(|(memory, cpu)| memory.is_empty() && cpu.is_empty());
408 if nothing_set {
409 let _ = fs::remove_file(&target);
412 return Ok(());
413 }
414
415 let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
416 let mut emit = |service: &str, limits: &(String, String)| {
417 if limits.0.is_empty() && limits.1.is_empty() {
418 return;
419 }
420 out.push_str(&format!(" {service}:\n"));
421 if !limits.0.is_empty() {
422 out.push_str(&format!(" mem_limit: {}\n", limits.0));
423 }
424 if !limits.1.is_empty() {
425 out.push_str(&format!(" cpus: {}\n", limits.1));
426 }
427 };
428 for service in FRONTEND_SERVICES.iter().chain(["desktop"].iter()) {
429 emit(service, &frontend);
430 }
431 for app in extra_apps(env) {
432 emit(&app, &frontend);
433 }
434 for service in ["backend", "queue", "scheduler"] {
435 emit(service, &backend);
436 }
437 emit("postgres", &postgres);
438 emit("mysql", &mysql);
439
440 write(target, &out)
441}
442
443fn write(path: std::path::PathBuf, contents: &str) -> Result<()> {
444 if let Some(parent) = path.parent() {
445 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
446 }
447 fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
448}
449
450#[cfg(test)]
451mod root_app_tests {
452 use super::*;
453
454 fn env_with(root_apps: &str) -> Env {
455 let dir = tempfile::tempdir().unwrap();
456 let path = dir.path().join(".env");
457 fs::write(&path, format!("ROOT_APPS={root_apps}\n")).unwrap();
458 Env::load(&path).unwrap()
459 }
460
461 #[test]
462 fn a_bare_name_uses_a_directory_of_the_same_name() {
463 let apps = root_apps(&env_with("seeder"));
464
465 assert_eq!(apps, vec![RootApp { name: "seeder".into(), dir: "seeder".into() }]);
466 }
467
468 #[test]
469 fn a_name_and_directory_can_differ() {
470 let apps = root_apps(&env_with("seeder:althaqeel-seeder"));
471
472 assert_eq!(apps[0].name, "seeder");
473 assert_eq!(apps[0].dir, "althaqeel-seeder");
474 }
475
476 #[test]
477 fn several_apps_are_read_in_order() {
478 let apps = root_apps(&env_with("seeder:althaqeel-seeder tools"));
479
480 assert_eq!(apps.len(), 2);
481 assert_eq!(apps[1].name, "tools");
482 }
483
484 #[test]
485 fn an_empty_setting_yields_nothing() {
486 assert!(root_apps(&env_with("")).is_empty());
487 }
488
489 #[test]
490 fn each_app_is_mounted_from_the_workspace_root() {
491 let env = env_with("seeder:althaqeel-seeder");
494 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
495
496 assert!(yaml.contains("- /w/althaqeel-seeder:/app"));
497 }
498
499 #[test]
500 fn the_command_is_not_given_vite_flags() {
501 let env = env_with("seeder:althaqeel-seeder");
504 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
505
506 assert!(yaml.contains(r#"command: ["root-app", "seeder", "4500"]"#));
507 assert!(!yaml.contains("--strictPort"));
508 }
509
510 #[test]
511 fn a_configured_port_wins_over_the_default() {
512 let dir = tempfile::tempdir().unwrap();
513 let path = dir.path().join(".env");
514 fs::write(&path, "ROOT_APPS=seeder\nSEEDER_PORT=4600\n").unwrap();
515 let env = Env::load(&path).unwrap();
516
517 assert!(render_root_apps(&env, Path::new("/w"), &root_apps(&env)).contains("\"4600:4600\""));
518 }
519
520 #[test]
521 fn each_app_declares_its_own_node_modules_volume() {
522 let env = env_with("seeder:althaqeel-seeder");
523 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
524
525 assert!(yaml.contains("- root_nm_seeder:/app/node_modules"));
526 assert!(yaml.contains("volumes:\n root_nm_seeder:"));
527 }
528
529 #[test]
530 fn the_overlay_is_removed_when_no_root_apps_remain() {
531 let dir = tempfile::tempdir().unwrap();
532 let target = dir.path().join("docker-compose.root-apps.yml");
533 fs::write(&target, "stale\n").unwrap();
534
535 root_apps_file(dir.path(), &env_with("")).unwrap();
536
537 assert!(!target.exists());
538 }
539}
540
541#[cfg(test)]
542mod root_app_mount_tests {
543 use super::*;
544
545 #[test]
546 fn a_root_app_mounts_the_entrypoint_like_the_frontend_does() {
547 let dir = tempfile::tempdir().unwrap();
550 let path = dir.path().join(".env");
551 fs::write(&path, "ROOT_APPS=seeder\n").unwrap();
552 let env = Env::load(&path).unwrap();
553
554 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
555
556 assert!(yaml.contains("./docker/frontend/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro"));
557 }
558}