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 +1 -0
- orch/cli.py +384 -0
- orch/config.py +84 -0
- orch/manager.py +273 -0
- orch/state.py +115 -0
- orch_cli-0.1.0.dist-info/METADATA +210 -0
- orch_cli-0.1.0.dist-info/RECORD +10 -0
- orch_cli-0.1.0.dist-info/WHEEL +4 -0
- orch_cli-0.1.0.dist-info/entry_points.txt +2 -0
- orch_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://pypi.org/project/orch-cli/)
|
|
17
|
+
[](https://opensource.org/licenses/MIT)
|
|
18
|
+
[](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,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.
|