orch-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
orch/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
orch/cli.py ADDED
@@ -0,0 +1,384 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional, List, Dict, Any
6
+ import typer
7
+ import psutil
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+ from rich.live import Live
11
+ from rich.markup import escape
12
+
13
+ from orch.config import load_config, find_config
14
+ from orch.manager import ProcessManager, LOG_COLORS
15
+ from orch.state import StateManager
16
+
17
+ app = typer.Typer(
18
+ name="orch",
19
+ help="Orch: Herramienta CLI de orquestación de procesos de desarrollo local y venvs",
20
+ no_args_is_help=True
21
+ )
22
+
23
+ console = Console()
24
+
25
+ def get_manager(config_path: Optional[Path]) -> ProcessManager:
26
+ """Helper to resolve config and return a ProcessManager instance."""
27
+ try:
28
+ cfg = load_config(config_path)
29
+ return ProcessManager(cfg, console)
30
+ except FileNotFoundError as e:
31
+ console.print(f"[bold red]Error:[/bold red] {e}")
32
+ raise typer.Exit(code=1)
33
+ except ValueError as e:
34
+ console.print(f"[bold red]Config Error:[/bold red] {e}")
35
+ raise typer.Exit(code=1)
36
+
37
+ @app.command()
38
+ def up(
39
+ config: Optional[Path] = typer.Option(
40
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
41
+ )
42
+ ):
43
+ """Ejecuta todos los servicios en primer plano (foreground), mostrando logs unificados."""
44
+ manager = get_manager(config)
45
+ manager.run_foreground()
46
+
47
+ @app.command()
48
+ def start(
49
+ config: Optional[Path] = typer.Option(
50
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
51
+ )
52
+ ):
53
+ """Inicia todos los servicios en segundo plano (detached) y guarda sus PIDs."""
54
+ manager = get_manager(config)
55
+ success = manager.run_background()
56
+ if not success:
57
+ raise typer.Exit(code=1)
58
+
59
+ @app.command()
60
+ def stop(
61
+ service: Optional[str] = typer.Argument(
62
+ None, help="Nombre del servicio específico a detener (ej. backend). Si se omite, detiene todos."
63
+ ),
64
+ config: Optional[Path] = typer.Option(
65
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
66
+ )
67
+ ):
68
+ """Detiene los servicios en ejecución en segundo plano."""
69
+ manager = get_manager(config)
70
+ if service:
71
+ manager.stop_service(service)
72
+ else:
73
+ manager.stop_all()
74
+
75
+ @app.command()
76
+ def restart(
77
+ service: Optional[str] = typer.Argument(
78
+ None, help="Nombre del servicio específico a reiniciar. Si se omite, reinicia todos."
79
+ ),
80
+ config: Optional[Path] = typer.Option(
81
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
82
+ )
83
+ ):
84
+ """Reinicia servicios activos en segundo plano."""
85
+ manager = get_manager(config)
86
+ active = manager.state_manager.get_active_services()
87
+
88
+ # Determine services to restart
89
+ to_restart = []
90
+ if service:
91
+ if service not in manager.config.services:
92
+ console.print(f"[bold red]Error:[/bold red] Service '{service}' is not defined in config.")
93
+ raise typer.Exit(code=1)
94
+ to_restart.append(service)
95
+ else:
96
+ to_restart = list(manager.config.services.keys())
97
+
98
+ for name in to_restart:
99
+ # Stop if running
100
+ if name in active:
101
+ manager.stop_service(name)
102
+ # Short sleep to let process release ports
103
+ time.sleep(0.5)
104
+
105
+ # Start again
106
+ # Temporarily adapt run_background to start a single service
107
+ # Let's do it inline to avoid modifying ProcessManager API
108
+ service_config = manager.config.services[name]
109
+ env = manager._prepare_env(service_config)
110
+ log_path = manager.state_manager.get_log_path(name)
111
+
112
+ with open(log_path, "a", encoding="utf-8") as log_file:
113
+ log_file.write(f"\n--- Service restarted at {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
114
+ log_file.flush()
115
+
116
+ log_f = open(log_path, "a", encoding="utf-8")
117
+ console.print(f"Starting [bold cyan]{name}[/bold cyan]...")
118
+ try:
119
+ import subprocess
120
+ kwargs = {
121
+ "shell": True,
122
+ "cwd": str(service_config.path),
123
+ "env": env,
124
+ "stdout": log_f,
125
+ "stderr": subprocess.STDOUT,
126
+ "stdin": subprocess.DEVNULL,
127
+ }
128
+ if sys.platform == "win32":
129
+ # CREATE_NEW_CONSOLE (0x10) | CREATE_NO_WINDOW (0x08000000) | CREATE_BREAKAWAY_FROM_JOB (0x01000000)
130
+ kwargs["creationflags"] = 0x00000010 | 0x08000000 | 0x01000000
131
+ else:
132
+ kwargs["start_new_session"] = True
133
+
134
+ proc = subprocess.Popen(service_config.command, **kwargs)
135
+ ps_proc = psutil.Process(proc.pid)
136
+ create_time = ps_proc.create_time()
137
+
138
+ manager.state_manager.update_service(name, proc.pid, service_config.command, create_time)
139
+ console.print(f" * [bold green]{name}[/bold green] restarted with PID {proc.pid}")
140
+ except Exception as e:
141
+ console.print(f" x [bold red]{name}[/bold red] failed to start: {e}")
142
+ finally:
143
+ log_f.close()
144
+
145
+ def format_uptime(seconds: float) -> str:
146
+ """Format seconds into hh:mm:ss."""
147
+ h = int(seconds // 3600)
148
+ m = int((seconds % 3600) // 60)
149
+ s = int(seconds % 60)
150
+ return f"{h:02d}:{m:02d}:{s:02d}"
151
+
152
+ @app.command()
153
+ def status(
154
+ config: Optional[Path] = typer.Option(
155
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
156
+ )
157
+ ):
158
+ """Muestra el estado de los servicios en segundo plano y su consumo de recursos."""
159
+ manager = get_manager(config)
160
+ # This updates stale services in state.json to CRASHED
161
+ active = manager.state_manager.get_active_services()
162
+ state = manager.state_manager.load_state()
163
+ services_state = state.get("services", {})
164
+
165
+ table = Table(title=f"Project: {manager.config.project_name} (Services Status)")
166
+ table.add_column("Service", style="cyan", no_wrap=True)
167
+ table.add_column("Status", justify="center")
168
+ table.add_column("PID", style="magenta", justify="center")
169
+ table.add_column("CPU %", justify="right")
170
+ table.add_column("Memory (MB)", justify="right")
171
+ table.add_column("Uptime", justify="center")
172
+
173
+ # In order to measure CPU percent properly on psutil, we should initialize it once,
174
+ # sleep briefly, and fetch again. We'll do a quick 0.1s check.
175
+ proc_objs = {}
176
+ for name, info in active.items():
177
+ pid = info["pid"]
178
+ try:
179
+ proc = psutil.Process(pid)
180
+ proc.cpu_percent(interval=None)
181
+ proc_objs[name] = proc
182
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
183
+ pass
184
+
185
+ time.sleep(0.1) # Brief sleep to get meaningful CPU readings
186
+
187
+ for name in manager.config.services.keys():
188
+ svc_state = services_state.get(name, {})
189
+ status_val = svc_state.get("status", "STOPPED")
190
+
191
+ if name in active and name in proc_objs:
192
+ proc = proc_objs[name]
193
+ try:
194
+ cpu = proc.cpu_percent(interval=None)
195
+ # Sum CPU of children processes as well (common for npm/sub-shells)
196
+ for child in proc.children(recursive=True):
197
+ try:
198
+ cpu += child.cpu_percent(interval=None)
199
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
200
+ pass
201
+
202
+ mem_bytes = proc.memory_info().rss
203
+ for child in proc.children(recursive=True):
204
+ try:
205
+ mem_bytes += child.memory_info().rss
206
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
207
+ pass
208
+
209
+ mem_mb = mem_bytes / (1024 * 1024)
210
+ uptime = time.time() - proc.create_time()
211
+
212
+ table.add_row(
213
+ name,
214
+ "[bold green]RUNNING[/bold green]",
215
+ str(proc.pid),
216
+ f"{cpu:.1f}%",
217
+ f"{mem_mb:.1f} MB",
218
+ format_uptime(uptime)
219
+ )
220
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
221
+ table.add_row(name, "[bold red]CRASHED[/bold red]", str(svc_state.get("pid", "-")), "0.0%", "0.0 MB", "00:00:00")
222
+ elif status_val == "CRASHED":
223
+ table.add_row(
224
+ name,
225
+ "[bold red]CRASHED[/bold red]",
226
+ str(svc_state.get("pid", "-")),
227
+ "0.0%",
228
+ "0.0 MB",
229
+ "00:00:00"
230
+ )
231
+ else:
232
+ table.add_row(
233
+ name,
234
+ "[bold yellow]STOPPED[/bold yellow]",
235
+ "-",
236
+ "-",
237
+ "-",
238
+ "-"
239
+ )
240
+
241
+ console.print(table)
242
+
243
+ def tail_file(filepath: Path, console: Console, prefix: str, color: str, stop_event: Any, lines_back: int = 50):
244
+ """Tail a single log file in a blocking thread."""
245
+ if not filepath.exists():
246
+ console.print(f"\\\\[[{color}]{prefix}[/{color}]] [yellow]Waiting for log file to be created...[/yellow]")
247
+ while not filepath.exists() and not stop_event.is_set():
248
+ time.sleep(0.5)
249
+
250
+ if stop_event.is_set():
251
+ return
252
+
253
+ # Read last lines_back lines
254
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
255
+ # Simple tail implementation
256
+ content = f.readlines()
257
+ last_lines = content[-lines_back:]
258
+ for line in last_lines:
259
+ clean_line = line.rstrip(chr(10)).rstrip(chr(13))
260
+ console.print(f"\\\\[[{color}]{prefix}[/{color}]] {escape(clean_line)}")
261
+
262
+ # Seek to end and tail
263
+ f.seek(0, os.SEEK_END)
264
+ while not stop_event.is_set():
265
+ line = f.readline()
266
+ if line:
267
+ clean_line = line.rstrip(chr(10)).rstrip(chr(13))
268
+ console.print(f"\\\\[[{color}]{prefix}[/{color}]] {escape(clean_line)}")
269
+ else:
270
+ time.sleep(0.1)
271
+
272
+ @app.command()
273
+ def logs(
274
+ service: Optional[str] = typer.Argument(
275
+ None, help="Nombre del servicio específico a consultar. Si se omite, muestra de todos."
276
+ ),
277
+ follow: bool = typer.Option(
278
+ False, "--follow", "-f", help="Seguir los logs en tiempo real (tail -f)"
279
+ ),
280
+ lines: int = typer.Option(
281
+ 50, "--lines", "-n", help="Número de líneas del historial a mostrar"
282
+ ),
283
+ config: Optional[Path] = typer.Option(
284
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
285
+ )
286
+ ):
287
+ """Muestra y sigue los logs de los servicios en segundo plano."""
288
+ manager = get_manager(config)
289
+ active = manager.state_manager.get_active_services()
290
+
291
+ services_to_show = []
292
+ if service:
293
+ if service not in manager.config.services:
294
+ console.print(f"[bold red]Error:[/bold red] Service '{service}' is not defined in config.")
295
+ raise typer.Exit(code=1)
296
+ services_to_show.append(service)
297
+ else:
298
+ services_to_show = list(manager.config.services.keys())
299
+
300
+ if not follow:
301
+ # Just display the last N lines
302
+ for name in services_to_show:
303
+ log_path = manager.state_manager.get_log_path(name)
304
+ console.print(f"\n--- [bold cyan]Logs for {name}[/bold cyan] ({log_path.name}) ---")
305
+ if not log_path.exists():
306
+ console.print("[yellow]No logs recorded yet.[/yellow]")
307
+ continue
308
+ with open(log_path, "r", encoding="utf-8", errors="replace") as f:
309
+ lines_list = f.readlines()
310
+ for line in lines_list[-lines:]:
311
+ print(line.rstrip("\r\n"))
312
+ else:
313
+ # Multi-file follow
314
+ import threading
315
+ stop_event = threading.Event()
316
+ threads = []
317
+
318
+ console.print("[bold green]Following logs in real-time. Press Ctrl+C to stop...[/bold green]\n")
319
+ try:
320
+ for idx, name in enumerate(services_to_show):
321
+ color = LOG_COLORS[idx % len(LOG_COLORS)]
322
+ log_path = manager.state_manager.get_log_path(name)
323
+ t = threading.Thread(
324
+ target=tail_file,
325
+ args=(log_path, console, name, color, stop_event, lines),
326
+ daemon=True
327
+ )
328
+ t.start()
329
+ threads.append(t)
330
+
331
+ # Wait loop until interrupted
332
+ while True:
333
+ time.sleep(0.5)
334
+ except KeyboardInterrupt:
335
+ console.print("\n[bold yellow]Stopping log follower...[/bold yellow]")
336
+ finally:
337
+ stop_event.set()
338
+ for t in threads:
339
+ t.join(timeout=1.0)
340
+
341
+ @app.command()
342
+ def clean(
343
+ config: Optional[Path] = typer.Option(
344
+ None, "--config", "-c", help="Ruta al archivo orch.yml / project.yml"
345
+ )
346
+ ):
347
+ """Detiene los servicios activos, vacía los logs y limpia el archivo de estado."""
348
+ manager = get_manager(config)
349
+
350
+ # 1. Stop background services if running
351
+ active = manager.state_manager.get_active_services()
352
+ if active:
353
+ console.print("[bold yellow]Deteniendo servicios activos antes de limpiar...[/bold yellow]")
354
+ manager.stop_all()
355
+ # Sleep briefly to ensure process cleanup
356
+ time.sleep(1.0)
357
+
358
+ # 2. Clean logs
359
+ logs_dir = manager.state_manager.logs_dir
360
+ if logs_dir.exists():
361
+ console.print("[bold blue]Limpiando archivos de logs...[/bold blue]")
362
+ for log_file in logs_dir.glob("*.log"):
363
+ try:
364
+ # Open in write-mode and close to truncate to 0 bytes
365
+ with open(log_file, "w", encoding="utf-8") as f:
366
+ pass
367
+ console.print(f" * Log {log_file.name} vaciado.")
368
+ except Exception as e:
369
+ console.print(f" x No se pudo vaciar {log_file.name}: {e}")
370
+
371
+ # 3. Clean state file
372
+ state_file = manager.state_manager.state_file
373
+ if state_file.exists():
374
+ console.print("[bold blue]Reseteando archivo de estado...[/bold blue]")
375
+ try:
376
+ manager.state_manager.save_state({"services": {}})
377
+ console.print(" * Estado reseteado.")
378
+ except Exception as e:
379
+ console.print(f" x No se pudo resetear el estado: {e}")
380
+
381
+ console.print("[bold green]Limpieza completada con éxito.[/bold green]")
382
+
383
+ if __name__ == "__main__":
384
+ app()
orch/config.py ADDED
@@ -0,0 +1,84 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Dict, Any, Optional, List
4
+ import yaml
5
+
6
+ class ServiceConfig:
7
+ def __init__(self, name: str, data: Dict[str, Any], base_dir: Path):
8
+ self.name = name
9
+ self.path = base_dir / Path(data.get("path", "."))
10
+ self.command = data.get("command")
11
+ self.env = data.get("env", {})
12
+ self.restart = data.get("restart", "no") # 'no', 'always', 'on-failure'
13
+
14
+ if not self.command:
15
+ raise ValueError(f"Service '{name}' must have a 'command' specified.")
16
+
17
+ def to_dict(self) -> Dict[str, Any]:
18
+ return {
19
+ "name": self.name,
20
+ "path": str(self.path),
21
+ "command": self.command,
22
+ "env": self.env,
23
+ "restart": self.restart,
24
+ }
25
+
26
+ class ProjectConfig:
27
+ def __init__(self, filepath: Path):
28
+ self.filepath = filepath.resolve()
29
+ self.base_dir = self.filepath.parent
30
+
31
+ with open(self.filepath, "r", encoding="utf-8") as f:
32
+ try:
33
+ self.data = yaml.safe_load(f) or {}
34
+ except yaml.YAMLError as e:
35
+ raise ValueError(f"Invalid YAML file: {e}")
36
+
37
+ self.project_name = self.data.get("project")
38
+ if not self.project_name:
39
+ raise ValueError("Config must specify a 'project' name.")
40
+
41
+ # Parse global environment config
42
+ env_data = self.data.get("env", {})
43
+ self.env_type = env_data.get("type")
44
+ self.env_path = None
45
+ if env_data.get("path"):
46
+ self.env_path = (self.base_dir / Path(env_data["path"])).resolve()
47
+ self.global_vars = env_data.get("variables", {})
48
+
49
+ # Parse services
50
+ services_data = self.data.get("services", {})
51
+ if not services_data:
52
+ raise ValueError("Config must define at least one service.")
53
+
54
+ self.services: Dict[str, ServiceConfig] = {}
55
+ for name, svc_data in services_data.items():
56
+ self.services[name] = ServiceConfig(name, svc_data, self.base_dir)
57
+
58
+ def find_config(search_path: Optional[Path] = None) -> Path:
59
+ """
60
+ Search for a default configuration file starting from search_path and walking up.
61
+ """
62
+ candidates = ["orch.yml", "orch.yaml", "project.yml", "project.yaml"]
63
+ current = Path(search_path or os.getcwd()).resolve()
64
+
65
+ # Search upwards
66
+ for parent in [current] + list(current.parents):
67
+ for candidate in candidates:
68
+ path = parent / candidate
69
+ if path.exists() and path.is_file():
70
+ return path
71
+
72
+ raise FileNotFoundError(
73
+ "Could not find orch.yml, orch.yaml, project.yml, or project.yaml in this directory or any parent directories."
74
+ )
75
+
76
+ def load_config(filepath: Optional[Path] = None) -> ProjectConfig:
77
+ if filepath is None:
78
+ filepath = find_config()
79
+ else:
80
+ filepath = Path(filepath).resolve()
81
+ if not filepath.exists():
82
+ raise FileNotFoundError(f"Configuration file not found: {filepath}")
83
+
84
+ return ProjectConfig(filepath)
orch/manager.py ADDED
@@ -0,0 +1,273 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Dict, List, Any, Optional
8
+ import psutil
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+
12
+ from orch.config import ProjectConfig, ServiceConfig
13
+ from orch.state import StateManager
14
+
15
+ # Colors for service logs
16
+ LOG_COLORS = ["cyan", "magenta", "green", "yellow", "blue", "bright_red", "bright_green", "bright_yellow", "bright_blue", "bright_magenta", "bright_cyan"]
17
+
18
+ class ProcessManager:
19
+ def __init__(self, config: ProjectConfig, console: Console):
20
+ self.config = config
21
+ self.console = console
22
+ self.state_manager = StateManager(config.base_dir)
23
+ self.active_processes: Dict[str, subprocess.Popen] = {}
24
+ self._threads: List[threading.Thread] = []
25
+ self._stop_event = threading.Event()
26
+
27
+ def _prepare_env(self, service: ServiceConfig) -> Dict[str, str]:
28
+ """Merge system env, global env variables, service env variables and inject venv if active."""
29
+ env = os.environ.copy()
30
+
31
+ # Inject global variables
32
+ for k, v in self.config.global_vars.items():
33
+ env[k] = str(v)
34
+
35
+ # Inject service-specific variables
36
+ for k, v in service.env.items():
37
+ env[k] = str(v)
38
+
39
+ # Inject virtual environment if configured
40
+ if self.config.env_type == "venv" and self.config.env_path:
41
+ venv_path = self.config.env_path
42
+
43
+ # Determine path to venv binary/scripts directory
44
+ if sys.platform == "win32":
45
+ bin_dir = venv_path / "Scripts"
46
+ else:
47
+ bin_dir = venv_path / "bin"
48
+
49
+ if bin_dir.exists():
50
+ # Prepend venv's bin to PATH
51
+ env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
52
+ env["VIRTUAL_ENV"] = str(venv_path)
53
+ # Remove PYTHONHOME if set to prevent issues with virtual envs
54
+ env.pop("PYTHONHOME", None)
55
+ else:
56
+ self.console.print(f"[yellow]Warning: Virtualenv path '{venv_path}' is configured, but '{bin_dir}' was not found.[/yellow]")
57
+
58
+ return env
59
+
60
+ def _read_stream(self, name: str, stream: Any, color: str):
61
+ """Read line by line from stream and print to console with colored prefix."""
62
+ while not self._stop_event.is_set():
63
+ line = stream.readline()
64
+ if not line:
65
+ break
66
+ # Decode and strip line breaks
67
+ decoded = line.rstrip("\r\n")
68
+ self.console.print(f"\\[[{color}]{name}[/{color}]] {escape(decoded)}")
69
+
70
+ def run_foreground(self):
71
+ """Run all services in foreground mode, multiplexing logs to stdout."""
72
+ self.console.print(f"[bold green]Starting project '{self.config.project_name}' in foreground mode...[/bold green]")
73
+ self.console.print("Press [bold red]Ctrl+C[/bold red] to stop all services.\n")
74
+
75
+ # Check if services are already running in background
76
+ active_background = self.state_manager.get_active_services()
77
+ if active_background:
78
+ self.console.print("[yellow]Warning: Some services are already running in the background. Running in foreground might cause port conflicts.[/yellow]")
79
+
80
+ services = list(self.config.services.values())
81
+
82
+ try:
83
+ for idx, service in enumerate(services):
84
+ color = LOG_COLORS[idx % len(LOG_COLORS)]
85
+ env = self._prepare_env(service)
86
+
87
+ self.console.print(f"[bold blue]Starting service '{service.name}'...[/bold blue]")
88
+
89
+ # On Windows, using shell=True allows running npm/python command strings naturally
90
+ # We merge stderr into stdout for combined streaming
91
+ proc = subprocess.Popen(
92
+ service.command,
93
+ shell=True,
94
+ cwd=str(service.path),
95
+ env=env,
96
+ stdout=subprocess.PIPE,
97
+ stderr=subprocess.STDOUT,
98
+ text=True,
99
+ encoding="utf-8",
100
+ errors="replace",
101
+ bufsize=1 # Line buffered
102
+ )
103
+
104
+ self.active_processes[service.name] = proc
105
+
106
+ # Start a thread to read output
107
+ t = threading.Thread(
108
+ target=self._read_stream,
109
+ args=(service.name, proc.stdout, color),
110
+ daemon=True
111
+ )
112
+ t.start()
113
+ self._threads.append(t)
114
+
115
+ # Wait loop to monitor processes
116
+ while self.active_processes:
117
+ dead_services = []
118
+ for name, proc in list(self.active_processes.items()):
119
+ ret = proc.poll()
120
+ if ret is not None:
121
+ self.console.print(f"[bold red][{name}] Process exited with code {ret}[/bold red]")
122
+ dead_services.append(name)
123
+
124
+ for name in dead_services:
125
+ del self.active_processes[name]
126
+
127
+ time.sleep(0.5)
128
+
129
+ except KeyboardInterrupt:
130
+ self.console.print("\n[bold yellow]Stopping all services...[/bold yellow]")
131
+ finally:
132
+ self.stop_all(foreground=True)
133
+
134
+ def run_background(self) -> bool:
135
+ """Start all services in detached background mode, writing logs to files."""
136
+ self.state_manager.ensure_dirs()
137
+ active = self.state_manager.get_active_services()
138
+
139
+ success = True
140
+ for name, service in self.config.services.items():
141
+ if name in active:
142
+ self.console.print(f"[yellow]Service '{name}' is already running (PID: {active[name]['pid']}). Skipping...[/yellow]")
143
+ continue
144
+
145
+ env = self._prepare_env(service)
146
+ log_path = self.state_manager.get_log_path(name)
147
+
148
+ # Open log file in append mode, write run header
149
+ with open(log_path, "a", encoding="utf-8") as log_file:
150
+ log_file.write(f"\n--- Service started at {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n")
151
+ log_file.flush()
152
+
153
+ # Open log file for sub-process stdout/stderr redirection
154
+ # We open in write mode (to append, we pass file handles)
155
+ log_f = open(log_path, "a", encoding="utf-8")
156
+
157
+ self.console.print(f"Starting [bold cyan]{name}[/bold cyan]...")
158
+ try:
159
+ # We need to spawn process detached so it survives parent exiting
160
+ # On Windows, we can use CREATE_NEW_PROCESS_GROUP
161
+ # On Unix, we can use start_new_session=True (or preexec_fn=os.setsid)
162
+ # stdout and stderr go directly to log file, stdin is DEVNULL
163
+ kwargs = {
164
+ "shell": True,
165
+ "cwd": str(service.path),
166
+ "env": env,
167
+ "stdout": log_f,
168
+ "stderr": subprocess.STDOUT,
169
+ "stdin": subprocess.DEVNULL,
170
+ }
171
+
172
+ if sys.platform == "win32":
173
+ # CREATE_NEW_PROCESS_GROUP (0x200) | CREATE_BREAKAWAY_FROM_JOB (0x01000000)
174
+ kwargs["creationflags"] = 0x00000200 | 0x01000000
175
+ else:
176
+ kwargs["start_new_session"] = True
177
+
178
+ proc = subprocess.Popen(service.command, **kwargs)
179
+
180
+ # Fetch create_time using psutil to prevent PID recycle issue
181
+ ps_proc = psutil.Process(proc.pid)
182
+ create_time = ps_proc.create_time()
183
+
184
+ self.state_manager.update_service(name, proc.pid, service.command, create_time)
185
+ self.console.print(f" * [bold green]{name}[/bold green] started with PID {proc.pid}")
186
+ except Exception as e:
187
+ self.console.print(f" x [bold red]{name}[/bold red] failed to start: {e}")
188
+ success = False
189
+ finally:
190
+ log_f.close()
191
+
192
+ return success
193
+
194
+ def stop_all(self, foreground: bool = False):
195
+ """Stop all processes. If foreground, stops active_processes. If background, reads state file."""
196
+ self._stop_event.set()
197
+
198
+ if foreground:
199
+ for name, proc in list(self.active_processes.items()):
200
+ self.console.print(f"Stopping [bold yellow]{name}[/bold yellow] (PID {proc.pid})...")
201
+ self.kill_process_tree(proc.pid)
202
+ self.active_processes.clear()
203
+ else:
204
+ state = self.state_manager.load_state()
205
+ services = state.get("services", {})
206
+
207
+ stopped_any = False
208
+ for name, info in services.items():
209
+ if info.get("status") == "STOPPED":
210
+ continue
211
+
212
+ pid = info.get("pid")
213
+ if pid:
214
+ self.console.print(f"Stopping [bold yellow]{name}[/bold yellow] (PID {pid})...")
215
+ self.kill_process_tree(pid)
216
+ else:
217
+ self.console.print(f"Clearing stale state for [bold yellow]{name}[/bold yellow]...")
218
+
219
+ self.state_manager.remove_service(name)
220
+ stopped_any = True
221
+
222
+ if stopped_any:
223
+ self.console.print("[bold green]All services stopped/cleared.[/bold green]")
224
+ else:
225
+ self.console.print("[yellow]No active or crashed services found to stop.[/yellow]")
226
+
227
+ def stop_service(self, name: str):
228
+ """Stop a specific background service."""
229
+ state = self.state_manager.load_state()
230
+ services = state.get("services", {})
231
+ if name not in services or services[name].get("status") == "STOPPED":
232
+ self.console.print(f"[yellow]Service '{name}' is not running in the background.[/yellow]")
233
+ return
234
+
235
+ info = services[name]
236
+ pid = info.get("pid")
237
+ if pid:
238
+ self.console.print(f"Stopping [bold yellow]{name}[/bold yellow] (PID {pid})...")
239
+ self.kill_process_tree(pid)
240
+
241
+ self.state_manager.remove_service(name)
242
+ self.console.print(f"[bold green]Service '{name}' stopped.[/bold green]")
243
+
244
+ def kill_process_tree(self, pid: int):
245
+ """Recursively terminate parent PID and all its child processes."""
246
+ try:
247
+ parent = psutil.Process(pid)
248
+ children = parent.children(recursive=True)
249
+
250
+ # Send terminate signal to children first
251
+ for child in children:
252
+ try:
253
+ child.terminate()
254
+ except psutil.NoSuchProcess:
255
+ pass
256
+
257
+ # Terminate parent
258
+ parent.terminate()
259
+
260
+ # Wait up to 2 seconds for clean termination
261
+ gone, alive = psutil.wait_procs(children + [parent], timeout=2.0)
262
+
263
+ # Force kill survivors
264
+ for survivor in alive:
265
+ try:
266
+ self.console.print(f" [red]Force killing survivor process PID {survivor.pid}[/red]")
267
+ survivor.kill()
268
+ except psutil.NoSuchProcess:
269
+ pass
270
+ except psutil.NoSuchProcess:
271
+ pass
272
+ except Exception as e:
273
+ self.console.print(f"[red]Error killing process tree for PID {pid}: {e}[/red]")
orch/state.py ADDED
@@ -0,0 +1,115 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Dict, Any, Optional
6
+ import psutil
7
+
8
+ class StateManager:
9
+ def __init__(self, base_dir: Path):
10
+ self.base_dir = base_dir.resolve()
11
+ self.orch_dir = self.base_dir / ".orch"
12
+ self.state_file = self.orch_dir / "state.json"
13
+ self.logs_dir = self.orch_dir / "logs"
14
+
15
+ def ensure_dirs(self):
16
+ """Ensure .orch and .orch/logs directories exist."""
17
+ self.orch_dir.mkdir(parents=True, exist_ok=True)
18
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
19
+
20
+ def load_state(self) -> Dict[str, Any]:
21
+ """Load state JSON, returning empty dict if not existing or invalid."""
22
+ if not self.state_file.exists():
23
+ return {"services": {}}
24
+ try:
25
+ with open(self.state_file, "r", encoding="utf-8") as f:
26
+ return json.load(f)
27
+ except (json.JSONDecodeError, OSError):
28
+ return {"services": {}}
29
+
30
+ def save_state(self, state: Dict[str, Any]):
31
+ """Save state dict to state.json."""
32
+ self.ensure_dirs()
33
+ with open(self.state_file, "w", encoding="utf-8") as f:
34
+ json.dump(state, f, indent=2)
35
+
36
+ def update_service(self, name: str, pid: int, command: str, create_time: float):
37
+ """Update or insert a running service entry."""
38
+ state = self.load_state()
39
+ state["services"][name] = {
40
+ "pid": pid,
41
+ "command": command,
42
+ "started_at": time.time(),
43
+ "create_time": create_time,
44
+ "log_file": str(self.get_log_path(name)),
45
+ "status": "RUNNING",
46
+ }
47
+ self.save_state(state)
48
+
49
+ def remove_service(self, name: str):
50
+ """Mark a service entry as STOPPED cleanly."""
51
+ state = self.load_state()
52
+ if name in state["services"]:
53
+ state["services"][name]["status"] = "STOPPED"
54
+ state["services"][name]["pid"] = None
55
+ state["services"][name]["create_time"] = None
56
+ else:
57
+ state["services"][name] = {
58
+ "pid": None,
59
+ "command": "",
60
+ "started_at": None,
61
+ "create_time": None,
62
+ "log_file": str(self.get_log_path(name)),
63
+ "status": "STOPPED",
64
+ }
65
+ self.save_state(state)
66
+
67
+ def get_log_path(self, name: str) -> Path:
68
+ """Get the absolute path to a service's log file."""
69
+ self.ensure_dirs()
70
+ return (self.logs_dir / f"{name}.log").resolve()
71
+
72
+ def get_active_services(self) -> Dict[str, Dict[str, Any]]:
73
+ """
74
+ Verify PIDs against OS processes. Returns active services only.
75
+ Updates stale entries to 'CRASHED' in the state file.
76
+ """
77
+ state = self.load_state()
78
+ services = state.get("services", {})
79
+ active = {}
80
+ changed = False
81
+
82
+ for name, info in list(services.items()):
83
+ if info.get("status") == "STOPPED":
84
+ continue
85
+
86
+ pid = info.get("pid")
87
+ saved_create_time = info.get("create_time")
88
+
89
+ is_alive = False
90
+ if pid and psutil.pid_exists(pid):
91
+ try:
92
+ proc = psutil.Process(pid)
93
+ # Check if create_time matches within a reasonable delta
94
+ proc_create_time = proc.create_time()
95
+ if saved_create_time and abs(proc_create_time - saved_create_time) < 1.5:
96
+ if proc.is_running() and proc.status() != psutil.STATUS_ZOMBIE:
97
+ is_alive = True
98
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
99
+ pass
100
+
101
+ if is_alive:
102
+ active[name] = info
103
+ if info.get("status") != "RUNNING":
104
+ info["status"] = "RUNNING"
105
+ changed = True
106
+ else:
107
+ # Process is dead, but was not stopped cleanly -> CRASHED
108
+ if info.get("status") != "CRASHED":
109
+ info["status"] = "CRASHED"
110
+ changed = True
111
+
112
+ if changed:
113
+ self.save_state(state)
114
+
115
+ return active
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: orch-cli
3
+ Version: 0.1.0
4
+ Summary: A local development process orchestrator and venv-aware runner
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.8
8
+ Requires-Dist: psutil>=5.9.0
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: rich>=13.0.0
11
+ Requires-Dist: typer[all]>=0.9.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Orch - Local Service Orchestrator
15
+
16
+ [![PyPI version](https://img.shields.io/pypi/v/orch-cli.svg)](https://pypi.org/project/orch-cli/)
17
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
18
+ [![Build Status](https://github.com/EASYLIFECODE/orch/workflows/CI/badge.svg)](https://github.com/EASYLIFECODE/orch/actions)
19
+
20
+ **Orch** is a lightweight, **daemon-less** command-line interface (CLI) tool designed to simplify local development of client-server or multi-service architectures.
21
+
22
+ It runs your scripts, servers, frontends, and workers concurrently, manages their Python virtual environments (`venv`) transparently, monitors their resources, and multiplexes their logs—**without** the overhead of Docker or background daemon processes.
23
+
24
+ ---
25
+
26
+ ## Key Features
27
+
28
+ - 🔌 **Daemon-less Architecture**: No persistent background services needed. State is safely tracked locally in `.orch/state.json`.
29
+ - 🐍 **Automatic venv Detection**: Injects your project's Python virtual environment (`venv`) to the process execution path automatically—no manual activation scripts required.
30
+ - 🎨 **Unified Multiplexed Logs**: Streams output from all services into a single terminal window with distinct, customizable color prefixes, protecting ANSI markup.
31
+ - 🌙 **Background Execution**: Spawn services in detached modes, persisting output to individual log files.
32
+ - 📊 **Resource Monitoring**: Track CPU, Memory consumption, and uptime per service with a single command.
33
+ - 🛡️ **Clean Process Tree Teardown**: Intercepts shutdown signals and recursively kills child processes (e.g. Node sub-processes, shell scripts) cleanly, avoiding orphaned processes.
34
+ - 🛡️ **PID Recycle Protection**: Safeguards against re-using recycled PIDs by validating the OS process creation time before killing or reporting status.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ ### Stable Release (via PyPI)
41
+
42
+ ```bash
43
+ pip install orch-cli
44
+ ```
45
+
46
+ ### From Source (Development Mode)
47
+
48
+ 1. Clone the repository:
49
+ ```bash
50
+ git clone https://github.com/EASYLIFECODE/orch.git
51
+ cd orch
52
+ ```
53
+
54
+ 2. Install in editable development mode:
55
+ ```bash
56
+ pip install -e .
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Configuration (`orch.yml`)
62
+
63
+ Configure your services in an `orch.yml` or `project.yml` file in the root of your project:
64
+
65
+ ```yaml
66
+ # orch.yml
67
+ project: my-awesome-app
68
+
69
+ # Global environment configuration
70
+ env:
71
+ type: venv # Type of virtual environment
72
+ path: ./venv # Path to the virtualenv folder
73
+ variables:
74
+ GLOBAL_VAR: "shared-value"
75
+ DATABASE_URL: "sqlite:///local.db"
76
+
77
+ services:
78
+ backend:
79
+ path: ./backend # Working directory for this service
80
+ command: python app.py # Automatically runs using the configured global venv
81
+ env:
82
+ PORT: "8000"
83
+
84
+ frontend:
85
+ path: ./frontend
86
+ command: npm run dev
87
+ env:
88
+ VITE_API_URL: "http://localhost:8000"
89
+
90
+ worker:
91
+ path: ./worker
92
+ command: python worker.py
93
+ ```
94
+
95
+ ---
96
+
97
+ ## CLI Usage
98
+
99
+ ### 1. Run in Foreground (Interactive Mode)
100
+
101
+ Launches all services concurrently, streaming their colored logs into the active console. Pressing `Ctrl+C` cleanly shuts down all services.
102
+
103
+ ```bash
104
+ orch up
105
+ ```
106
+
107
+ ### 2. Run in Background (Detached Mode)
108
+
109
+ Launches all services in the background and saves their state.
110
+
111
+ ```bash
112
+ orch start
113
+ ```
114
+
115
+ ### 3. Check Service Status & Resource Usage
116
+
117
+ Displays an elegant table showing running status, PID, CPU, Memory usage (recursive of all child processes), and uptime.
118
+
119
+ ```bash
120
+ orch status
121
+ ```
122
+
123
+ *Example Output:*
124
+ ```text
125
+ Project: my-awesome-app (Services Status)
126
+ ┌──────────┬─────────┬───────┬───────┬─────────────┬──────────┐
127
+ │ Service │ Status │ PID │ CPU % │ Memory (MB) │ Uptime │
128
+ ├──────────┼─────────┼───────┼───────┼─────────────┼──────────┤
129
+ │ backend │ RUNNING │ 12452 │ 0.2% │ 45.4 MB │ 00:12:30 │
130
+ │ frontend │ RUNNING │ 12459 │ 0.0% │ 82.1 MB │ 00:12:30 │
131
+ │ worker │ RUNNING │ 12461 │ 1.5% │ 32.7 MB │ 00:12:28 │
132
+ └──────────┴─────────┴───────┴───────┴─────────────┴──────────┘
133
+ ```
134
+
135
+ ### 4. Consult and Tail Logs
136
+
137
+ Tail log files of services running in the background.
138
+
139
+ ```bash
140
+ # Follow logs of all services
141
+ orch logs -f
142
+
143
+ # Follow logs of a specific service
144
+ orch logs backend -f
145
+
146
+ # Read last 100 lines without following
147
+ orch logs frontend --lines 100
148
+ ```
149
+
150
+ ### 5. Restart Services
151
+
152
+ Restarts all or specific services running in the background.
153
+
154
+ ```bash
155
+ # Restart all
156
+ orch restart
157
+
158
+ # Restart only backend
159
+ orch restart backend
160
+ ```
161
+
162
+ ### 6. Stop Services
163
+
164
+ Stops services running in the background and cleans up state.
165
+
166
+ ```bash
167
+ # Stop all services
168
+ orch stop
169
+
170
+ # Stop a specific service
171
+ orch stop backend
172
+ ```
173
+
174
+ ### 7. Clean State & Logs
175
+
176
+ Terminates background services, clears log files (`.orch/logs/*.log`) to 0 bytes, and resets the state file.
177
+
178
+ ```bash
179
+ orch clean
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Technical Architecture
185
+
186
+ Orch is built using **Python 3.8+**, **Typer** for the CLI framework, **Rich** for visual output rendering, and **psutil** for platform-independent process controls.
187
+
188
+ ```text
189
+ [ CLI Command: orch ]
190
+
191
+ [ Load Configuration ]
192
+
193
+ [ Validate YAML Schema ]
194
+
195
+ [ Process Execution ]
196
+
197
+ ┌────────────────────┴────────────────────┐
198
+ [ Foreground Mode ] [ Background Mode ]
199
+ (Multiplex to Terminal) (Write to Log Files)
200
+ │ │
201
+ ├── Thread 1 (stdout) ├── .orch/logs/backend.log
202
+ ├── Thread 2 (stdout) ├── .orch/logs/frontend.log
203
+ └── SIGINT cleanup └── Keep PIDs in .orch/state.json
204
+ ```
205
+
206
+ ---
207
+
208
+ ## License
209
+
210
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -0,0 +1,10 @@
1
+ orch/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ orch/cli.py,sha256=G9w4j7Owo-Ic9FzY2uMKhezeyvMO8CDwoQB0TpdYJVY,14334
3
+ orch/config.py,sha256=zPgYT4MCpwgf-0P97nCFPl7Um7Hf7Gabma7DLdpJcps,3057
4
+ orch/manager.py,sha256=r_TMuWRKGBmU3s7KZQct9WfvYInW_CIePPrtbcS8W6o,11569
5
+ orch/state.py,sha256=G_qoZBUOva5BQj9FQggyHxFWzn_V6RDn6c2pxSlYKGw,4191
6
+ orch_cli-0.1.0.dist-info/METADATA,sha256=oBXP3TgIhxsDxASaMhx5yQ-GqCEJSnpeKZLT64rPrZE,6808
7
+ orch_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ orch_cli-0.1.0.dist-info/entry_points.txt,sha256=EpMKCtXt47wWaG7uM1m8pdYOLyeJtgLQPUSzH_NwBjY,38
9
+ orch_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Vno9RhyRE70OW8p_3mHDMutHBEbQBAzMQtOBxhkC3JE,1100
10
+ orch_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orch = orch.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EASYLIFECODE / Anthony Mosquera (4m12-code)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.