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(" dashboard:\n environment:\n");
133 out.push_str(&format!(
134 " ROOT_APPS: \"{}\"\n",
135 apps.iter()
136 .map(|app| if app.name == app.dir {
137 app.name.clone()
138 } else {
139 format!("{}:{}", app.name, app.dir)
140 })
141 .collect::<Vec<_>>()
142 .join(" ")
143 ));
144 for (index, app) in apps.iter().enumerate() {
145 out.push_str(&format!(
146 " {}_PORT: \"{}\"\n",
147 key_of(&app.name),
148 root_app_port(env, app, index)
149 ));
150 }
151 out.push('\n');
152
153 out.push_str("volumes:\n");
154 for app in apps {
155 out.push_str(&format!(" {}:\n", root_volume_name(&app.name)));
156 }
157 out
158}
159
160fn root_volume_name(name: &str) -> String {
161 format!("root_nm_{}", name.replace(['/', '-'], "_"))
162}
163
164fn package_dirs(frontend: &Path) -> Vec<String> {
166 let mut found = Vec::new();
167 for root in ["apps", "packages"] {
168 let Ok(entries) = fs::read_dir(frontend.join(root)) else {
169 continue;
170 };
171 for entry in entries.flatten() {
172 if entry.path().join("package.json").is_file() {
173 found.push(format!(
174 "{root}/{}",
175 entry.file_name().to_string_lossy()
176 ));
177 }
178 }
179 }
180 found.sort();
181 found
182}
183
184fn volume_name(dir: &str) -> String {
186 format!(
187 "fe_nm_{}",
188 dir.replace(['/', '-'], "_")
189 )
190}
191
192fn extra_apps(env: &Env) -> Vec<String> {
193 env.get_or("EXTRA_APPS", "")
194 .split_whitespace()
195 .map(|name| name.split(':').next().unwrap_or(name).to_string())
196 .collect()
197}
198
199fn mobile_enabled(env: &Env) -> bool {
200 env.is_true("RUN_MOBILE", false)
201}
202
203fn packages(run_dir: &Path, env: &Env) -> Result<()> {
205 let frontend = Path::new(env.get_or("FRONTEND_DIR", ""));
206 let dirs = package_dirs(frontend);
207 let stack = env.get_or("BACKEND_STACK", "laravel").to_string();
208 let backend_in_frontend =
209 env.get_or("BACKEND_DIR", "").trim_end_matches('/') == env.get_or("FRONTEND_DIR", "").trim_end_matches('/');
210 let subdir = env.get_or("BACKEND_SUBDIR", "").trim_end_matches('/').to_string();
211
212 let mut services: Vec<String> = FRONTEND_SERVICES.iter().map(|s| s.to_string()).collect();
213 services.extend(extra_apps(env));
214
215 let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
216
217 if stack == "node" {
220 for service in ["backend", "queue", "scheduler"] {
221 out.push_str(&format!(" {service}:\n volumes:\n"));
222 out.push_str(" - pnpm_store:/pnpm-store\n");
223 out.push_str(" - fe_nm_root:/app/node_modules\n");
224 if backend_in_frontend {
225 for dir in &dirs {
226 out.push_str(&format!(" - {}:/app/{dir}/node_modules\n", volume_name(dir)));
227 }
228 out.push_str(" depends_on:\n frontend-deps:\n condition: service_completed_successfully\n");
229 } else if !subdir.is_empty() {
230 out.push_str(&format!(" - be_node_modules:/app/{subdir}/node_modules\n"));
231 }
232 }
233 }
234
235 for service in &services {
236 out.push_str(&format!(" {service}:\n"));
237 if !mobile_enabled(env) && METRO_SERVICES.contains(&service.as_str()) {
241 out.push_str(" profiles: [\"__no_mobile\"]\n");
242 }
243 if dirs.is_empty() {
244 continue;
245 }
246 out.push_str(" volumes:\n");
247 for dir in &dirs {
248 out.push_str(&format!(" - {}:/app/{dir}/node_modules\n", volume_name(dir)));
249 }
250 }
251
252 out.push_str("volumes:\n");
253 if stack == "node" && !backend_in_frontend && !subdir.is_empty() {
254 out.push_str(" be_node_modules:\n");
255 }
256 for dir in &dirs {
257 out.push_str(&format!(" {}:\n", volume_name(dir)));
258 }
259
260 write(run_dir.join("docker-compose.packages.yml"), &out)
261}
262
263fn apps(run_dir: &Path, env: &Env, package_dir: &Path) -> Result<()> {
269 let apps = extra_apps(env);
270 let target = run_dir.join("docker-compose.extra.yml");
271 if apps.is_empty() {
272 let _ = fs::remove_file(&target);
273 return Ok(());
274 }
275
276 let base = fs::read_to_string(package_dir.join("docker-compose.yml"))
277 .with_context(|| format!("reading {}", package_dir.join("docker-compose.yml").display()))?;
278 let anchors: String = base
279 .lines()
280 .skip_while(|line| !line.starts_with("x-"))
281 .take_while(|line| !line.starts_with("services:"))
282 .map(|line| format!("{line}\n"))
283 .collect();
284
285 let frontend = Path::new(env.get_or("FRONTEND_DIR", "")).to_path_buf();
286 let mut out = String::from("# Generated by run-stack — do not edit.\n");
287 out.push_str("# One service per EXTRA_APPS entry; edit run.config.toml and re-run up.\n");
288 out.push_str(&anchors);
289 out.push_str("services:\n");
290
291 out.push_str(" frontend-deps:\n environment:\n <<: *frontend-env\n");
294 out.push_str(&format!(
295 " EXTRA_DEPS_APPS: \"${{EXTRA_DEPS_APPS:-}} {} \"\n\n",
296 apps.join(" ")
297 ));
298
299 let mut metro = Vec::new();
300 out.push_str(" dashboard:\n environment:\n");
301 out.push_str(&format!(" EXTRA_APPS: \"{}\"\n", env.get_or("EXTRA_APPS", "")));
302 for app in &apps {
303 out.push_str(&format!(
304 " {}_PORT: \"{}\"\n",
305 key_of(app),
306 port_of(env, app, &frontend)
307 ));
308 if is_metro_app(&frontend, app) {
309 metro.push(app.clone());
310 }
311 }
312 out.push_str(&format!(" EXTRA_APPS_MOBILE: \"{}\"\n\n", metro.join(" ")));
313
314 for app in &apps {
315 let key = key_of(app);
316 let port = port_of(env, app, &frontend);
317 out.push_str(&format!(" {app}:\n <<: *frontend-service\n environment:\n <<: *frontend-env\n"));
318 out.push_str(&format!(" APP_CMD: ${{{key}_CMD:-}}\n"));
319 if metro.contains(app) {
320 out.push_str(&format!(" MOBILE_APP: \"{app}\"\n"));
322 out.push_str(&format!(" MOBILE_STACK: ${{{key}_STACK:-auto}}\n"));
323 out.push_str(" EXPO_NO_TELEMETRY: \"1\"\n");
324 out.push_str(" EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0\n");
325 out.push_str(" REACT_NATIVE_PACKAGER_HOSTNAME: ${REACT_NATIVE_PACKAGER_HOSTNAME:-localhost}\n");
326 out.push_str(&format!(" RCT_METRO_PORT: \"{port}\"\n"));
327 out.push_str(" EXPO_PUBLIC_API_BASE_URL: ${EXPO_PUBLIC_API_BASE_URL:-http://localhost:8000/api}\n");
328 out.push_str(&format!(" command: [\"metro\", \"{app}\", \"{port}\"]\n"));
329 } else {
330 out.push_str(&format!(" command: [\"app\", \"{app}\", \"{port}\"]\n"));
331 }
332 out.push_str(&format!(" ports:\n - \"{port}:{port}\"\n\n"));
333 }
334
335 write(target, &out)
336}
337
338fn is_metro_app(frontend: &Path, app: &str) -> bool {
341 let Some(dir) = app_dir(frontend, app) else {
342 return false;
343 };
344 let Ok(text) = fs::read_to_string(dir.join("package.json")) else {
345 return false;
346 };
347 let Ok(package) = serde_json::from_str::<serde_json::Value>(&text) else {
348 return false;
349 };
350 ["dependencies", "devDependencies"].iter().any(|section| {
351 package[section]
352 .as_object()
353 .is_some_and(|deps| {
354 deps.keys().any(|name| {
355 name == "expo"
356 || name.starts_with("expo-")
357 || name == "react-native"
358 || name.starts_with("react-native-")
359 })
360 })
361 })
362}
363
364fn app_dir(frontend: &Path, app: &str) -> Option<std::path::PathBuf> {
365 for root in ["apps", "packages"] {
366 let direct = frontend.join(root).join(app);
367 if direct.join("package.json").is_file() {
368 return Some(direct);
369 }
370 if let Ok(entries) = fs::read_dir(frontend.join(root)) {
372 for entry in entries.flatten() {
373 let manifest = entry.path().join("package.json");
374 let Ok(text) = fs::read_to_string(&manifest) else {
375 continue;
376 };
377 let named = serde_json::from_str::<serde_json::Value>(&text)
378 .ok()
379 .and_then(|package| package["name"].as_str().map(str::to_string));
380 if named.as_deref() == Some(app) {
381 return Some(entry.path());
382 }
383 }
384 }
385 }
386 None
387}
388
389fn port_of(env: &Env, app: &str, frontend: &Path) -> u16 {
390 let key = format!("{}_PORT", key_of(app));
391 if let Some(port) = env.get(&key).and_then(|value| value.trim().parse().ok()) {
392 return port;
393 }
394 if is_metro_app(frontend, app) {
395 8082
396 } else {
397 5180
398 }
399}
400
401fn resources(run_dir: &Path, env: &Env) -> Result<()> {
403 let target = run_dir.join("docker-compose.resources.yml");
404 let pick = |specific: &str, global: &str| -> String {
405 let value = env.get_or(specific, "");
406 if !value.is_empty() {
407 return value.to_string();
408 }
409 env.get_or(global, "").to_string()
410 };
411 let frontend = (
412 pick("FRONTEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
413 pick("FRONTEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
414 );
415 let backend = (
416 pick("BACKEND_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
417 pick("BACKEND_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
418 );
419 let postgres = (
420 pick("POSTGRES_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
421 pick("POSTGRES_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
422 );
423 let mysql = (
424 pick("MYSQL_MEMORY_LIMIT", "DOCKER_MEMORY_LIMIT"),
425 pick("MYSQL_CPU_LIMIT", "DOCKER_CPU_LIMIT"),
426 );
427
428 let nothing_set = [&frontend, &backend, &postgres, &mysql]
429 .iter()
430 .all(|(memory, cpu)| memory.is_empty() && cpu.is_empty());
431 if nothing_set {
432 let _ = fs::remove_file(&target);
435 return Ok(());
436 }
437
438 let mut out = String::from("# Generated by run-stack — do not edit.\nservices:\n");
439 let mut emit = |service: &str, limits: &(String, String)| {
440 if limits.0.is_empty() && limits.1.is_empty() {
441 return;
442 }
443 out.push_str(&format!(" {service}:\n"));
444 if !limits.0.is_empty() {
445 out.push_str(&format!(" mem_limit: {}\n", limits.0));
446 }
447 if !limits.1.is_empty() {
448 out.push_str(&format!(" cpus: {}\n", limits.1));
449 }
450 };
451 for service in FRONTEND_SERVICES.iter().chain(["desktop"].iter()) {
452 emit(service, &frontend);
453 }
454 for app in extra_apps(env) {
455 emit(&app, &frontend);
456 }
457 for service in ["backend", "queue", "scheduler"] {
458 emit(service, &backend);
459 }
460 emit("postgres", &postgres);
461 emit("mysql", &mysql);
462
463 write(target, &out)
464}
465
466fn write(path: std::path::PathBuf, contents: &str) -> Result<()> {
467 if let Some(parent) = path.parent() {
468 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
469 }
470 fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))
471}
472
473#[cfg(test)]
474mod root_app_tests {
475 use super::*;
476
477 fn env_with(root_apps: &str) -> Env {
478 let dir = tempfile::tempdir().unwrap();
479 let path = dir.path().join(".env");
480 fs::write(&path, format!("ROOT_APPS={root_apps}\n")).unwrap();
481 Env::load(&path).unwrap()
482 }
483
484 #[test]
485 fn a_bare_name_uses_a_directory_of_the_same_name() {
486 let apps = root_apps(&env_with("seeder"));
487
488 assert_eq!(apps, vec![RootApp { name: "seeder".into(), dir: "seeder".into() }]);
489 }
490
491 #[test]
492 fn a_name_and_directory_can_differ() {
493 let apps = root_apps(&env_with("seeder:althaqeel-seeder"));
494
495 assert_eq!(apps[0].name, "seeder");
496 assert_eq!(apps[0].dir, "althaqeel-seeder");
497 }
498
499 #[test]
500 fn several_apps_are_read_in_order() {
501 let apps = root_apps(&env_with("seeder:althaqeel-seeder tools"));
502
503 assert_eq!(apps.len(), 2);
504 assert_eq!(apps[1].name, "tools");
505 }
506
507 #[test]
508 fn an_empty_setting_yields_nothing() {
509 assert!(root_apps(&env_with("")).is_empty());
510 }
511
512 #[test]
513 fn each_app_is_mounted_from_the_workspace_root() {
514 let env = env_with("seeder:althaqeel-seeder");
517 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
518
519 assert!(yaml.contains("- /w/althaqeel-seeder:/app"));
520 }
521
522 #[test]
523 fn the_command_is_not_given_vite_flags() {
524 let env = env_with("seeder:althaqeel-seeder");
527 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
528
529 assert!(yaml.contains(r#"command: ["root-app", "seeder", "4500"]"#));
530 assert!(!yaml.contains("--strictPort"));
531 }
532
533 #[test]
534 fn a_configured_port_wins_over_the_default() {
535 let dir = tempfile::tempdir().unwrap();
536 let path = dir.path().join(".env");
537 fs::write(&path, "ROOT_APPS=seeder\nSEEDER_PORT=4600\n").unwrap();
538 let env = Env::load(&path).unwrap();
539
540 assert!(render_root_apps(&env, Path::new("/w"), &root_apps(&env)).contains("\"4600:4600\""));
541 }
542
543 #[test]
544 fn each_app_declares_its_own_node_modules_volume() {
545 let env = env_with("seeder:althaqeel-seeder");
546 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
547
548 assert!(yaml.contains("- root_nm_seeder:/app/node_modules"));
549 assert!(yaml.contains("volumes:\n root_nm_seeder:"));
550 }
551
552 #[test]
553 fn the_overlay_is_removed_when_no_root_apps_remain() {
554 let dir = tempfile::tempdir().unwrap();
555 let target = dir.path().join("docker-compose.root-apps.yml");
556 fs::write(&target, "stale\n").unwrap();
557
558 root_apps_file(dir.path(), &env_with("")).unwrap();
559
560 assert!(!target.exists());
561 }
562}
563
564#[cfg(test)]
565mod root_app_mount_tests {
566 use super::*;
567
568 #[test]
569 fn a_root_app_mounts_the_entrypoint_like_the_frontend_does() {
570 let dir = tempfile::tempdir().unwrap();
573 let path = dir.path().join(".env");
574 fs::write(&path, "ROOT_APPS=seeder\n").unwrap();
575 let env = Env::load(&path).unwrap();
576
577 let yaml = render_root_apps(&env, Path::new("/w"), &root_apps(&env));
578
579 assert!(yaml.contains("./docker/frontend/entrypoint.sh:/usr/local/bin/entrypoint.sh:ro"));
580 }
581}