stackhelx 1.0.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.
- stackhelx/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
stackhelx/cli.py
ADDED
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
"""CLI de StackHelx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from . import (
|
|
14
|
+
__version__,
|
|
15
|
+
config,
|
|
16
|
+
detect,
|
|
17
|
+
docker,
|
|
18
|
+
doctor,
|
|
19
|
+
history,
|
|
20
|
+
mcp,
|
|
21
|
+
ports,
|
|
22
|
+
registry,
|
|
23
|
+
runner,
|
|
24
|
+
scripts,
|
|
25
|
+
tunnel,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
app = typer.Typer(
|
|
29
|
+
help="Orquestador de entornos de desarrollo locales.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
add_completion=False,
|
|
32
|
+
)
|
|
33
|
+
console = Console()
|
|
34
|
+
err = Console(stderr=True)
|
|
35
|
+
|
|
36
|
+
PortArg = typer.Argument(..., min=1, max=65535)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _row(status: ports.PortStatus, cmd_width: int) -> tuple[str, ...]:
|
|
40
|
+
if status.free:
|
|
41
|
+
return (str(status.port), "[green]libre[/]", "-", "-", "-")
|
|
42
|
+
if status.owner_unknown:
|
|
43
|
+
return (str(status.port), "[red]ocupado[/]", "?", "[dim]sin permisos[/]", "-")
|
|
44
|
+
cmd = status.cmdline or "-"
|
|
45
|
+
if len(cmd) > cmd_width:
|
|
46
|
+
cmd = cmd[: cmd_width - 1] + "…"
|
|
47
|
+
return (str(status.port), "[red]ocupado[/]", str(status.pid), status.name or "?", cmd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("ports")
|
|
51
|
+
def ports_cmd(
|
|
52
|
+
port: list[int] = typer.Argument(None, min=1, max=65535),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Revisa puertos. Sin argumentos, usa los del stack (declarados o detectados)."""
|
|
55
|
+
if not port:
|
|
56
|
+
try:
|
|
57
|
+
stack = detect.stack_for(Path.cwd())
|
|
58
|
+
except config.ConfigError as exc:
|
|
59
|
+
err.print(f"{exc}\nPasa los puertos como argumento: stackhelx ports 3000 8080")
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
port = stack.ports()
|
|
62
|
+
if not port:
|
|
63
|
+
err.print(
|
|
64
|
+
f"{stack.path} no declara ningun puerto. Los servicios con "
|
|
65
|
+
"'ready: listen' recien lo tienen cuando arrancan."
|
|
66
|
+
)
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
table = Table(box=None, pad_edge=False)
|
|
70
|
+
for column in ("PUERTO", "ESTADO", "PID", "PROCESO", "COMANDO"):
|
|
71
|
+
table.add_column(column)
|
|
72
|
+
# Presupuesto para el comando: lo que sobra tras las cuatro columnas fijas.
|
|
73
|
+
cmd_width = max(20, console.width - 34)
|
|
74
|
+
scanned = ports.scan_many(port)
|
|
75
|
+
for value in port:
|
|
76
|
+
table.add_row(*_row(scanned[value], cmd_width))
|
|
77
|
+
console.print(table)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _release(
|
|
81
|
+
port: int,
|
|
82
|
+
yes: bool,
|
|
83
|
+
force: bool,
|
|
84
|
+
expected_pid: int | None = None,
|
|
85
|
+
expected_create_time: float | None = None,
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Libera un puerto ocupado. Si se pasan expected_pid y expected_create_time,
|
|
88
|
+
verifica que el proceso no haya cambiado entre la confirmacion y el cierre.
|
|
89
|
+
"""
|
|
90
|
+
status = ports.scan(port)
|
|
91
|
+
if status.free:
|
|
92
|
+
console.print(f"Puerto {port} ya estaba libre.")
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
if status.pid is None:
|
|
96
|
+
err.print(
|
|
97
|
+
f"Puerto {port} ocupado, pero el proceso no es visible con estos "
|
|
98
|
+
"permisos. Proba desde una terminal con privilegios."
|
|
99
|
+
)
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
if expected_pid is not None and status.pid != expected_pid:
|
|
103
|
+
err.print(
|
|
104
|
+
f"El puerto {port} cambio de proceso (ahora es PID {status.pid} [{status.name}]). "
|
|
105
|
+
"Salteado por seguridad."
|
|
106
|
+
)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
target_pid = expected_pid if expected_pid is not None else status.pid
|
|
110
|
+
target_create_time = (
|
|
111
|
+
expected_create_time if expected_create_time is not None else status.create_time
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if expected_pid is None:
|
|
115
|
+
console.print(f"Puerto {port} ocupado por PID {status.pid} ([bold]{status.name}[/])")
|
|
116
|
+
if status.cmdline:
|
|
117
|
+
console.print(f" [dim]{status.cmdline}[/]")
|
|
118
|
+
|
|
119
|
+
if not yes and not typer.confirm(f"Cerrar el PID {status.pid}?"):
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
ports.kill(target_pid, target_create_time, force=force, port=port)
|
|
124
|
+
except ports.KillRefused as exc:
|
|
125
|
+
err.print(f"Rechazado: {exc}")
|
|
126
|
+
return False
|
|
127
|
+
except psutil.NoSuchProcess:
|
|
128
|
+
pass
|
|
129
|
+
except psutil.AccessDenied:
|
|
130
|
+
err.print(
|
|
131
|
+
f"Sin permisos para cerrar el PID {target_pid}. "
|
|
132
|
+
"Proba desde una terminal con privilegios."
|
|
133
|
+
)
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
console.print(f"PID {target_pid} cerrado. Puerto {port} [green]libre[/].")
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command("free")
|
|
141
|
+
def free_cmd(
|
|
142
|
+
port: int | None = typer.Argument(None, help="Puerto a liberar (ej: 8080)."),
|
|
143
|
+
all_ports: bool = typer.Option(
|
|
144
|
+
False, "--all", "-a", help="Liberar todos los puertos intrusos de los proyectos registrados."
|
|
145
|
+
),
|
|
146
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar antes de matar."),
|
|
147
|
+
force: bool = typer.Option(False, "--force", help="kill() si ignora terminate()."),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Libera un puerto ocupado o todos los puertos intrusos de proyectos registrados."""
|
|
150
|
+
if not all_ports and port is None:
|
|
151
|
+
err.print("Especifica un puerto (ej: stackhelx free 8080) o el flag --all")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
if all_ports:
|
|
155
|
+
return _free_all(yes, force)
|
|
156
|
+
|
|
157
|
+
if ports.is_free(port):
|
|
158
|
+
console.print(f"Puerto {port} [green]libre[/].")
|
|
159
|
+
return
|
|
160
|
+
if not _release(port, yes, force):
|
|
161
|
+
console.print(f"Siguiente puerto libre: [bold]{ports.next_free(port)}[/]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _free_all(yes: bool, force: bool) -> None:
|
|
165
|
+
"""Cierra lo que ocupa los puertos declarados por los proyectos registrados.
|
|
166
|
+
|
|
167
|
+
A diferencia de la interfaz, el CLI no tiene sesiones: no sabe cuales de
|
|
168
|
+
esos procesos los arranco StackHelx en otra terminal. Por eso lista todo
|
|
169
|
+
antes de tocar nada, y el texto no promete que sean ajenos.
|
|
170
|
+
"""
|
|
171
|
+
encontrados = registry.find_orphans()
|
|
172
|
+
if not encontrados:
|
|
173
|
+
console.print("Ningun puerto de tus proyectos registrados esta ocupado.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
177
|
+
for item in encontrados:
|
|
178
|
+
# Los proyectos que reclaman ese puerto, no el dueño del proceso: el
|
|
179
|
+
# proceso es un desconocido y por eso esta en esta lista.
|
|
180
|
+
reclaman = ", ".join(item["projects"])
|
|
181
|
+
table.add_row(
|
|
182
|
+
f" [bold]:{item['port']}[/]",
|
|
183
|
+
f"{item['name']} (pid {item['pid']})",
|
|
184
|
+
f"[dim]lo declara {reclaman}[/]",
|
|
185
|
+
)
|
|
186
|
+
console.print(f"Ocupando puertos de tus proyectos ({len(encontrados)}):")
|
|
187
|
+
console.print(table)
|
|
188
|
+
console.print("[dim]Si alguno lo arrancaste vos desde otra terminal, tambien se cierra.[/]")
|
|
189
|
+
if not yes and not typer.confirm("Cerrarlos?", default=False):
|
|
190
|
+
console.print("Cancelado.")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
liberados = sum(
|
|
194
|
+
1
|
|
195
|
+
for item in encontrados
|
|
196
|
+
if _release(
|
|
197
|
+
item["port"],
|
|
198
|
+
yes=True,
|
|
199
|
+
force=force,
|
|
200
|
+
expected_pid=item["pid"],
|
|
201
|
+
expected_create_time=item["create_time"],
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
color = "green" if liberados == len(encontrados) else "yellow"
|
|
205
|
+
console.print(f"[{color}]{liberados} de {len(encontrados)} cerrados.[/]")
|
|
206
|
+
if liberados < len(encontrados):
|
|
207
|
+
raise typer.Exit(1)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _confirm_detected(services: list[config.Service], yes: bool) -> bool:
|
|
211
|
+
"""Muestra lo detectado y pide confirmacion: nadie quiere arrancar a ciegas."""
|
|
212
|
+
console.print("[dim]Sin stack.yaml. Detectado:[/]")
|
|
213
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
214
|
+
for service in services:
|
|
215
|
+
port = str(service.port) if service.port else "[dim]al arrancar[/]"
|
|
216
|
+
table.add_row(f" [bold]{service.name}[/]", service.command, port)
|
|
217
|
+
console.print(table)
|
|
218
|
+
console.print("[dim]Para congelarlo en un archivo editable: stackhelx init[/]")
|
|
219
|
+
return yes or typer.confirm("Arrancar?", default=True)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _free_ports(services: list[config.Service], yes: bool, force: bool) -> None:
|
|
223
|
+
"""Libera los puertos declarados que tenga otro proceso, antes de arrancar."""
|
|
224
|
+
for service in services:
|
|
225
|
+
if not service.port:
|
|
226
|
+
continue
|
|
227
|
+
status = ports.scan(service.port)
|
|
228
|
+
if status.free:
|
|
229
|
+
continue
|
|
230
|
+
motor = ports.proxy_owner(status)
|
|
231
|
+
if motor:
|
|
232
|
+
console.print(
|
|
233
|
+
f"[dim]{service.name}: el puerto {service.port} ya lo publica {motor}, "
|
|
234
|
+
f"no hay nada que liberar.[/]"
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
if not _release(service.port, yes, force):
|
|
238
|
+
err.print(f"Puerto {service.port} sigue ocupado ({service.name}). Cancelado.")
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command("up")
|
|
243
|
+
def up_cmd(
|
|
244
|
+
profile: str = typer.Option(None, "--profile", "-p", help="Perfil de stack.yaml."),
|
|
245
|
+
env_file: str = typer.Option(None, "--env-file", "-e", help="Ruta al archivo .env personalizado."),
|
|
246
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar nada, arrancar."),
|
|
247
|
+
force: bool = typer.Option(False, "--force", help="kill() si ignora terminate()."),
|
|
248
|
+
free: bool = typer.Option(
|
|
249
|
+
True, "--free/--no-free", help="Liberar los puertos declarados antes de arrancar."
|
|
250
|
+
),
|
|
251
|
+
timeout: float = typer.Option(60.0, help="Segundos de espera por servicio."),
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Levanta el stack: libera puertos, arranca en orden y sigue los logs."""
|
|
254
|
+
_levantar(Path.cwd(), profile, yes, force, free, timeout, env_file=env_file)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _levantar(
|
|
258
|
+
root: Path,
|
|
259
|
+
profile: str | None,
|
|
260
|
+
yes: bool,
|
|
261
|
+
force: bool,
|
|
262
|
+
free: bool,
|
|
263
|
+
timeout: float,
|
|
264
|
+
env_file: str | None = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""El cuerpo de `up`, por raiz explicita. `switch` levanta otro directorio."""
|
|
267
|
+
extra_env: dict[str, str] = {}
|
|
268
|
+
if env_file:
|
|
269
|
+
custom_env = (root / env_file).resolve()
|
|
270
|
+
if not custom_env.is_file():
|
|
271
|
+
err.print(f"No se encontró el archivo env: {env_file}")
|
|
272
|
+
raise typer.Exit(1)
|
|
273
|
+
extra_env = config.parse_env_file(custom_env)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
stack = detect.stack_for(root)
|
|
277
|
+
services = stack.resolve(profile)
|
|
278
|
+
except config.ConfigError as exc:
|
|
279
|
+
err.print(str(exc))
|
|
280
|
+
raise typer.Exit(1)
|
|
281
|
+
|
|
282
|
+
console.print(f"[bold]{stack.name}[/] [dim]{stack.path}[/]")
|
|
283
|
+
|
|
284
|
+
if stack.detected and not _confirm_detected(services, yes):
|
|
285
|
+
raise typer.Exit(1)
|
|
286
|
+
|
|
287
|
+
if free:
|
|
288
|
+
_free_ports(services, yes, force)
|
|
289
|
+
|
|
290
|
+
engine = runner.Runner(stack, console=console, timeout=timeout, extra_env=extra_env)
|
|
291
|
+
try:
|
|
292
|
+
engine.up(profile)
|
|
293
|
+
except runner.StartupError as exc:
|
|
294
|
+
err.print(f"Fallo el arranque: {exc}")
|
|
295
|
+
raise typer.Exit(1)
|
|
296
|
+
|
|
297
|
+
if all(p.service.detached for p in engine.procs):
|
|
298
|
+
console.print(
|
|
299
|
+
"[green]Todo listo.[/] Servicios detached, nada que seguir. "
|
|
300
|
+
"Para bajarlos: [bold]stackhelx down[/]"
|
|
301
|
+
)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
console.print("[green]Todo listo.[/] Ctrl-C para apagar.")
|
|
305
|
+
try:
|
|
306
|
+
engine.follow()
|
|
307
|
+
except KeyboardInterrupt:
|
|
308
|
+
console.print()
|
|
309
|
+
finally:
|
|
310
|
+
engine.down()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
MARCA = {"ok": "[green]ok [/]", "warn": "[yellow]aviso[/]", "fail": "[red]FALLA[/]"}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command("doctor")
|
|
317
|
+
def doctor_cmd() -> None:
|
|
318
|
+
"""Revisa que puede impedir el arranque, sin arrancar nada."""
|
|
319
|
+
checks = doctor.run(Path.cwd())
|
|
320
|
+
|
|
321
|
+
ancho = max(len(c.name) for c in checks)
|
|
322
|
+
for check in checks:
|
|
323
|
+
# Sin `highlight`: rich le pone color a los numeros y a las rutas, y
|
|
324
|
+
# esta salida es para copiar y pegar, no para mirar.
|
|
325
|
+
console.print(
|
|
326
|
+
f"{MARCA[check.level]} {check.name.ljust(ancho)} {check.detail}", highlight=False
|
|
327
|
+
)
|
|
328
|
+
if check.fix and check.level != "ok":
|
|
329
|
+
console.print(f"{' ' * (ancho + 8)}[dim]-> {check.fix}[/]", highlight=False)
|
|
330
|
+
|
|
331
|
+
if doctor.blocking(checks):
|
|
332
|
+
raise typer.Exit(1)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _correr_stops(apagables: list[config.Service]) -> int:
|
|
336
|
+
"""Corre los `stop:` en el orden que se le da. Devuelve cuantos fallaron."""
|
|
337
|
+
fallaron = 0
|
|
338
|
+
for service in apagables:
|
|
339
|
+
console.print(f"[dim]{service.name} | $ {service.stop}[/]")
|
|
340
|
+
done = runner.run_stop(service)
|
|
341
|
+
if done is None:
|
|
342
|
+
err.print(f"{service.name}: el apagado no termino en {runner.STOP_TIMEOUT}s")
|
|
343
|
+
fallaron += 1
|
|
344
|
+
continue
|
|
345
|
+
for line in (done.stdout or "").splitlines():
|
|
346
|
+
console.print(f"[dim]{service.name} |[/] {line.rstrip()}")
|
|
347
|
+
if done.returncode != 0:
|
|
348
|
+
err.print(f"{service.name}: el apagado fallo con codigo {done.returncode}")
|
|
349
|
+
fallaron += 1
|
|
350
|
+
return fallaron
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@app.command("down")
|
|
354
|
+
def down_cmd(
|
|
355
|
+
profile: str = typer.Option(None, "--profile", "-p", help="Perfil de stack.yaml."),
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Apaga lo que sobrevive a la terminal: contenedores y demas servicios detached."""
|
|
358
|
+
try:
|
|
359
|
+
stack = detect.stack_for(Path.cwd())
|
|
360
|
+
services = stack.resolve(profile)
|
|
361
|
+
except config.ConfigError as exc:
|
|
362
|
+
err.print(str(exc))
|
|
363
|
+
raise typer.Exit(1)
|
|
364
|
+
|
|
365
|
+
console.print(f"[bold]{stack.name}[/] [dim]{stack.path}[/]")
|
|
366
|
+
|
|
367
|
+
# En orden inverso al de arranque, igual que `Runner.down`: lo que depende
|
|
368
|
+
# de algo se baja antes que aquello de lo que depende.
|
|
369
|
+
apagables = [s for s in reversed(services) if s.stop]
|
|
370
|
+
if not apagables:
|
|
371
|
+
console.print(
|
|
372
|
+
"Ningun servicio declara [bold]stop[/]. Los que arranca "
|
|
373
|
+
"[bold]stackhelx up[/] son hijos de esa terminal y se apagan con Ctrl-C."
|
|
374
|
+
)
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
if _correr_stops(apagables):
|
|
378
|
+
raise typer.Exit(1)
|
|
379
|
+
console.print("[green]Apagado.[/]")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@app.command("switch")
|
|
383
|
+
def switch_cmd(
|
|
384
|
+
proyecto: str = typer.Argument(..., help="Nombre o ruta de un proyecto registrado."),
|
|
385
|
+
profile: str = typer.Option(None, "--profile", "-p", help="Perfil de stack.yaml."),
|
|
386
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar nada, arrancar."),
|
|
387
|
+
force: bool = typer.Option(False, "--force", help="kill() si ignora terminate()."),
|
|
388
|
+
timeout: float = typer.Option(60.0, help="Segundos de espera por servicio."),
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Baja los proyectos que le pisan los puertos a este, y lo levanta.
|
|
391
|
+
|
|
392
|
+
Rotar entre proyectos con puertos que se pisan son hoy tres comandos en tres
|
|
393
|
+
carpetas. El registro sabe quien declara que puerto, asi que puede hacerlo
|
|
394
|
+
solo.
|
|
395
|
+
"""
|
|
396
|
+
root = _resolver_proyecto(proyecto)
|
|
397
|
+
try:
|
|
398
|
+
stack = detect.stack_for(root)
|
|
399
|
+
services = stack.resolve(profile)
|
|
400
|
+
except config.ConfigError as exc:
|
|
401
|
+
err.print(f"{root}: {exc}")
|
|
402
|
+
raise typer.Exit(1)
|
|
403
|
+
|
|
404
|
+
for rival in _rivales(root, services):
|
|
405
|
+
_bajar(rival)
|
|
406
|
+
|
|
407
|
+
_levantar(root, profile, yes, force, True, timeout)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resolver_proyecto(nombre: str) -> Path:
|
|
411
|
+
"""Un proyecto registrado, por nombre de carpeta o por ruta."""
|
|
412
|
+
conocidos = registry.paths()
|
|
413
|
+
if not conocidos:
|
|
414
|
+
err.print("No hay proyectos registrados. Registra uno con: stackhelx add .")
|
|
415
|
+
raise typer.Exit(1)
|
|
416
|
+
|
|
417
|
+
candidato = Path(nombre).expanduser()
|
|
418
|
+
if candidato.is_dir() and candidato.resolve() in conocidos:
|
|
419
|
+
return candidato.resolve()
|
|
420
|
+
|
|
421
|
+
iguales = [p for p in conocidos if p.name.lower() == nombre.lower()]
|
|
422
|
+
if len(iguales) == 1:
|
|
423
|
+
return iguales[0]
|
|
424
|
+
if iguales:
|
|
425
|
+
err.print(f"'{nombre}' es ambiguo: {', '.join(str(p) for p in iguales)}")
|
|
426
|
+
raise typer.Exit(1)
|
|
427
|
+
|
|
428
|
+
err.print(
|
|
429
|
+
f"'{nombre}' no es un proyecto registrado. "
|
|
430
|
+
f"Conocidos: {', '.join(sorted(p.name for p in conocidos))}"
|
|
431
|
+
)
|
|
432
|
+
raise typer.Exit(1)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _rivales(root: Path, services: list[config.Service]) -> list[Path]:
|
|
436
|
+
"""Proyectos registrados que declaran alguno de los puertos de `services`.
|
|
437
|
+
|
|
438
|
+
Solo los que chocan, y no todo lo registrado: parar una base de datos que
|
|
439
|
+
nadie disputa no ayuda a arrancar, y es lo que mas cuesta volver a levantar.
|
|
440
|
+
Cuando todo choca, esto ya es todo lo demas.
|
|
441
|
+
"""
|
|
442
|
+
wanted = {s.port for s in services if s.port}
|
|
443
|
+
if not wanted:
|
|
444
|
+
return []
|
|
445
|
+
mapa = registry.declared_ports()
|
|
446
|
+
return sorted({p for port in wanted for p in mapa.get(port, []) if p != root})
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _bajar(root: Path) -> None:
|
|
450
|
+
"""Corre los `stop:` de otro proyecto. Un fallo avisa y no corta el switch.
|
|
451
|
+
|
|
452
|
+
Lo que no tiene `stop:` no se toca aca: son hijos de otra terminal, y si
|
|
453
|
+
igual siguen ocupando el puerto los agarra `_free_ports` al levantar, que ya
|
|
454
|
+
pregunta antes de matar a nadie.
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
stack = detect.stack_for(root)
|
|
458
|
+
apagables = [s for s in reversed(stack.resolve()) if s.stop]
|
|
459
|
+
except config.ConfigError as exc:
|
|
460
|
+
err.print(f"[dim]{root.name}: no se pudo leer para bajarlo ({exc})[/]")
|
|
461
|
+
return
|
|
462
|
+
if not apagables:
|
|
463
|
+
return
|
|
464
|
+
console.print(f"[bold]Bajando {stack.name}[/] [dim]{root}[/]")
|
|
465
|
+
if _correr_stops(apagables):
|
|
466
|
+
err.print(f"{stack.name}: quedo algo sin bajar, el puerto puede seguir ocupado.")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _abrir(url: str) -> None:
|
|
470
|
+
console.print(f"Abriendo [bold]{url}[/]")
|
|
471
|
+
import webbrowser
|
|
472
|
+
|
|
473
|
+
webbrowser.open(url)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@app.command("open")
|
|
477
|
+
def open_cmd(
|
|
478
|
+
port: int = typer.Argument(None, min=1, max=65535, help="Puerto, si ya lo sabes."),
|
|
479
|
+
) -> None:
|
|
480
|
+
"""Abre en el navegador el primer servicio del stack que conteste HTTP."""
|
|
481
|
+
if port is None:
|
|
482
|
+
try:
|
|
483
|
+
stack = detect.stack_for(Path.cwd())
|
|
484
|
+
except config.ConfigError as exc:
|
|
485
|
+
err.print(f"{exc}\nPasa el puerto como argumento: stackhelx open 3000")
|
|
486
|
+
raise typer.Exit(1)
|
|
487
|
+
# En orden de arranque: los contenedores primero, el frontend al final.
|
|
488
|
+
# Se recorre al reves porque lo que uno quiere abrir suele ser lo ultimo.
|
|
489
|
+
# Cada candidato es (url declarada o None, puerto o None): un puerto
|
|
490
|
+
# suelto no es un servicio y fabricarle uno seria un objeto que miente.
|
|
491
|
+
candidates = [
|
|
492
|
+
(runner.service_url(s), s.port)
|
|
493
|
+
for s in reversed(stack.resolve())
|
|
494
|
+
if s.port or s.url
|
|
495
|
+
]
|
|
496
|
+
else:
|
|
497
|
+
candidates = [(None, port)]
|
|
498
|
+
|
|
499
|
+
for declarada, puerto in candidates:
|
|
500
|
+
# Sin puerto no hay que sondear: no hay nada que preguntar. Puede abrir
|
|
501
|
+
# una pestaña muerta si el stack no esta arriba, que es exactamente lo
|
|
502
|
+
# que pasa hoy escribiendo la URL a mano, y es preferible a no poder
|
|
503
|
+
# abrirla nunca.
|
|
504
|
+
if puerto is None:
|
|
505
|
+
# Una `url:` con una variable sin valor no resuelve, y sin `port:` no
|
|
506
|
+
# hay a que caer: `_abrir(None)` reventaba con un TypeError crudo en
|
|
507
|
+
# la terminal. Saltearlo deja contestar al candidato siguiente, y si
|
|
508
|
+
# no queda ninguno cae en el error de abajo, que es lo que hay que
|
|
509
|
+
# leer cuando falta la variable.
|
|
510
|
+
if declarada is None:
|
|
511
|
+
continue
|
|
512
|
+
_abrir(declarada)
|
|
513
|
+
return
|
|
514
|
+
if runner.speaks_http(puerto):
|
|
515
|
+
_abrir(declarada or f"http://localhost:{puerto}")
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
err.print(
|
|
519
|
+
"Ningun puerto del stack contesta HTTP. "
|
|
520
|
+
"Arrancalo con 'stackhelx up' o pasa el puerto como argumento."
|
|
521
|
+
)
|
|
522
|
+
raise typer.Exit(1)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@app.command("add")
|
|
526
|
+
def add_cmd(
|
|
527
|
+
path: str = typer.Argument(".", help="Directorio del proyecto."),
|
|
528
|
+
) -> None:
|
|
529
|
+
"""Registra un proyecto para que aparezca en la interfaz."""
|
|
530
|
+
try:
|
|
531
|
+
registered = registry.add(path)
|
|
532
|
+
except registry.RegistryError as exc:
|
|
533
|
+
err.print(str(exc))
|
|
534
|
+
raise typer.Exit(1)
|
|
535
|
+
console.print(f"Registrado: [bold]{registered}[/]")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@app.command("list")
|
|
539
|
+
@app.command("ls")
|
|
540
|
+
def list_cmd() -> None:
|
|
541
|
+
"""Lista los proyectos registrados para la interfaz web."""
|
|
542
|
+
items = registry.paths()
|
|
543
|
+
if not items:
|
|
544
|
+
console.print("[dim]No hay proyectos registrados. Registra uno con: stackhelx add <ruta>[/]")
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
declared = registry.declared_ports()
|
|
548
|
+
collisions = registry.find_collisions()
|
|
549
|
+
|
|
550
|
+
table = Table(box=None, pad_edge=False)
|
|
551
|
+
table.add_column("ID")
|
|
552
|
+
table.add_column("NOMBRE")
|
|
553
|
+
table.add_column("PUERTOS")
|
|
554
|
+
table.add_column("RUTA")
|
|
555
|
+
for path in items:
|
|
556
|
+
pid = registry.project_id(path)
|
|
557
|
+
proj_ports = sorted(p for p, projs in declared.items() if path in projs)
|
|
558
|
+
port_labels = []
|
|
559
|
+
for p in proj_ports:
|
|
560
|
+
if p in collisions:
|
|
561
|
+
port_labels.append(f"[yellow]{p}[/]")
|
|
562
|
+
else:
|
|
563
|
+
port_labels.append(str(p))
|
|
564
|
+
ports_str = ", ".join(port_labels) if port_labels else "-"
|
|
565
|
+
table.add_row(pid, registry.name_of(path), ports_str, str(path))
|
|
566
|
+
console.print(table)
|
|
567
|
+
if collisions:
|
|
568
|
+
console.print("[dim yellow]Puertos en amarillo se disputan entre dos o mas proyectos.[/]")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@app.command("remove")
|
|
573
|
+
@app.command("rm")
|
|
574
|
+
def remove_cmd(
|
|
575
|
+
target: str = typer.Argument(..., help="Ruta o ID del proyecto a des-registrar."),
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Des-registra un proyecto de la interfaz."""
|
|
578
|
+
pids = {registry.project_id(p): p for p in registry.paths()}
|
|
579
|
+
if target in pids:
|
|
580
|
+
pid = target
|
|
581
|
+
path = pids[pid]
|
|
582
|
+
else:
|
|
583
|
+
resolved = Path(target).expanduser().resolve()
|
|
584
|
+
pid = registry.project_id(resolved)
|
|
585
|
+
path = resolved
|
|
586
|
+
|
|
587
|
+
if registry.remove(pid):
|
|
588
|
+
console.print(f"Quitado: [bold]{path}[/]")
|
|
589
|
+
else:
|
|
590
|
+
err.print(f"El proyecto '{target}' no esta registrado.")
|
|
591
|
+
raise typer.Exit(1)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@app.command("init")
|
|
595
|
+
def init_cmd(
|
|
596
|
+
path: str = typer.Argument(".", help="Directorio del proyecto."),
|
|
597
|
+
) -> None:
|
|
598
|
+
"""Escribe un stack.yaml con lo detectado, para editarlo a mano."""
|
|
599
|
+
root = Path(path).expanduser().resolve()
|
|
600
|
+
try:
|
|
601
|
+
target = detect.freeze(root)
|
|
602
|
+
except config.ConfigError as exc:
|
|
603
|
+
err.print(str(exc))
|
|
604
|
+
raise typer.Exit(1)
|
|
605
|
+
|
|
606
|
+
console.print(f"Escrito: [bold]{target}[/]")
|
|
607
|
+
console.print("[dim]Revisalo antes de confiar en el.[/]")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
@app.command("export")
|
|
611
|
+
def export_cmd(
|
|
612
|
+
out: Path | None = typer.Argument(None, help="Archivo JSON de destino (opcional)."),
|
|
613
|
+
) -> None:
|
|
614
|
+
"""Exporta las rutas de todos los proyectos registrados en formato JSON."""
|
|
615
|
+
data = registry.export_data()
|
|
616
|
+
text = json.dumps(data, indent=2)
|
|
617
|
+
if out:
|
|
618
|
+
out.write_text(text, encoding="utf-8")
|
|
619
|
+
console.print(f"Exportados {len(data)} proyectos en [bold]{out}[/]")
|
|
620
|
+
else:
|
|
621
|
+
console.print(text)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
@app.command("import")
|
|
625
|
+
def import_cmd(
|
|
626
|
+
src: Path = typer.Argument(..., help="Archivo JSON con rutas de proyectos."),
|
|
627
|
+
) -> None:
|
|
628
|
+
"""Importa masivamente proyectos registrados desde un archivo JSON."""
|
|
629
|
+
if not src.is_file():
|
|
630
|
+
err.print(f"El archivo '{src}' no existe.")
|
|
631
|
+
raise typer.Exit(1)
|
|
632
|
+
try:
|
|
633
|
+
data = json.loads(src.read_text(encoding="utf-8"))
|
|
634
|
+
imported = registry.import_data(data)
|
|
635
|
+
console.print(f"Importados [bold]{len(imported)}[/] proyectos desde [bold]{src}[/]")
|
|
636
|
+
except Exception as exc:
|
|
637
|
+
err.print(f"Error al importar desde '{src}': {exc}")
|
|
638
|
+
raise typer.Exit(1)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@app.command("serve")
|
|
642
|
+
def serve_cmd(
|
|
643
|
+
port: int = typer.Option(7666, min=1, max=65535, help="Puerto de la interfaz."),
|
|
644
|
+
no_open: bool = typer.Option(False, "--no-open", help="No abrir el navegador."),
|
|
645
|
+
verbose: bool = typer.Option(
|
|
646
|
+
False, "--verbose", "-v", help="Registrar cada peticion que llega."
|
|
647
|
+
),
|
|
648
|
+
) -> None:
|
|
649
|
+
"""Levanta la interfaz web local."""
|
|
650
|
+
try:
|
|
651
|
+
import uvicorn
|
|
652
|
+
|
|
653
|
+
from . import server
|
|
654
|
+
except ImportError:
|
|
655
|
+
err.print("Falta fastapi o uvicorn. Reinstala stackhelx: pipx reinstall stackhelx")
|
|
656
|
+
raise typer.Exit(1)
|
|
657
|
+
|
|
658
|
+
# Antes de imprimir la URL: uvicorn atrapa el error de bind y sale por su
|
|
659
|
+
# cuenta, asi que el `except OSError` de abajo nunca corria y el usuario
|
|
660
|
+
# veia un ERROR de winerror en vez del comando que lo arregla. Averiguar
|
|
661
|
+
# quien tiene un puerto es lo que esta herramienta hace.
|
|
662
|
+
if not ports.is_free(port):
|
|
663
|
+
ocupante = ports.scan(port)
|
|
664
|
+
quien = ocupante.name or "un proceso desconocido"
|
|
665
|
+
err.print(f"El puerto {port} ya esta ocupado por {quien} (pid {ocupante.pid}).")
|
|
666
|
+
try:
|
|
667
|
+
suggested = ports.suggest_alternative(port)
|
|
668
|
+
err.print(f"Cerralo con: stackhelx free {port} o arranca con: --port {suggested}")
|
|
669
|
+
except Exception:
|
|
670
|
+
err.print(f"Cerralo con: stackhelx free {port} o arranca con: --port <otro>")
|
|
671
|
+
raise typer.Exit(1)
|
|
672
|
+
|
|
673
|
+
token = registry.token()
|
|
674
|
+
url = f"http://127.0.0.1:{port}/?token={token}"
|
|
675
|
+
|
|
676
|
+
console.print(f"StackHelx en [bold]http://127.0.0.1:{port}[/]")
|
|
677
|
+
console.print("[dim]Solo loopback. El token va en la URL de abajo.[/]")
|
|
678
|
+
console.print(url)
|
|
679
|
+
|
|
680
|
+
if not no_open:
|
|
681
|
+
import webbrowser
|
|
682
|
+
|
|
683
|
+
webbrowser.open(url)
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
# Por defecto callado: la interfaz sondea cada 2.5s y el log de acceso
|
|
687
|
+
# tapa cualquier otra cosa. Con --verbose se ve que pide el navegador,
|
|
688
|
+
# que es la unica forma de saber si esta hablando con este servidor o
|
|
689
|
+
# mostrando una pagina vieja de su cache.
|
|
690
|
+
uvicorn.run(
|
|
691
|
+
server.create_app(token),
|
|
692
|
+
host="127.0.0.1",
|
|
693
|
+
port=port,
|
|
694
|
+
log_level="info" if verbose else "warning",
|
|
695
|
+
access_log=verbose,
|
|
696
|
+
)
|
|
697
|
+
except OSError as exc:
|
|
698
|
+
err.print(f"No se pudo iniciar el servidor en 127.0.0.1:{port}: {exc}")
|
|
699
|
+
err.print(f"El puerto {port} esta ocupado. Podes usar 'stackhelx free {port}' o '--port <otro>'.")
|
|
700
|
+
raise typer.Exit(1)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _version(pedido: bool) -> None:
|
|
704
|
+
if pedido:
|
|
705
|
+
console.print(__version__)
|
|
706
|
+
raise typer.Exit()
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# El flag ademas del subcomando: `--version` es lo que prueba cualquiera que
|
|
710
|
+
# acaba de instalar la herramienta, y `no_args_is_help` hace que un `stackhelx`
|
|
711
|
+
# pelado muestre la ayuda antes de llegar aca. Eager para que conteste sin pedir
|
|
712
|
+
# un comando.
|
|
713
|
+
@app.callback()
|
|
714
|
+
def _root(
|
|
715
|
+
version: bool = typer.Option(
|
|
716
|
+
False, "--version", callback=_version, is_eager=True,
|
|
717
|
+
help="Muestra la version instalada.",
|
|
718
|
+
),
|
|
719
|
+
) -> None:
|
|
720
|
+
pass
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@app.command(
|
|
724
|
+
"run",
|
|
725
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
726
|
+
)
|
|
727
|
+
def run_cmd(
|
|
728
|
+
ctx: typer.Context,
|
|
729
|
+
script: str = typer.Argument(None, help="Nombre del script a ejecutar"),
|
|
730
|
+
) -> None:
|
|
731
|
+
"""Ejecuta un script o pipeline de tareas definido en stack.yaml."""
|
|
732
|
+
try:
|
|
733
|
+
stack = detect.stack_for(Path.cwd())
|
|
734
|
+
except config.ConfigError as exc:
|
|
735
|
+
err.print(f"{exc}")
|
|
736
|
+
raise typer.Exit(1)
|
|
737
|
+
|
|
738
|
+
if not script:
|
|
739
|
+
if not stack.scripts:
|
|
740
|
+
console.print("[dim]No hay scripts declarados en stack.yaml.[/]")
|
|
741
|
+
raise typer.Exit(0)
|
|
742
|
+
table = Table(box=None, pad_edge=False)
|
|
743
|
+
table.add_column("SCRIPT", style="bold cyan")
|
|
744
|
+
table.add_column("COMANDOS")
|
|
745
|
+
for name, cmds in stack.scripts.items():
|
|
746
|
+
table.add_row(name, " && ".join(cmds))
|
|
747
|
+
console.print(table)
|
|
748
|
+
raise typer.Exit(0)
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
code = scripts.run_script(stack, script, extra_args=ctx.args, console=console)
|
|
752
|
+
except config.ConfigError as exc:
|
|
753
|
+
err.print(f"{exc}")
|
|
754
|
+
raise typer.Exit(1)
|
|
755
|
+
|
|
756
|
+
if code != 0:
|
|
757
|
+
raise typer.Exit(code)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@app.command("share")
|
|
761
|
+
def share_cmd(
|
|
762
|
+
target: str = typer.Argument(
|
|
763
|
+
None,
|
|
764
|
+
help="Servicio o puerto a compartir (ej. 3000, web). Sin argumentos, usa el puerto principal.",
|
|
765
|
+
),
|
|
766
|
+
provider: str = typer.Option(
|
|
767
|
+
None,
|
|
768
|
+
"--provider",
|
|
769
|
+
"-p",
|
|
770
|
+
help="Proveedor de tuneles: cloudflared, ngrok, lt, tailscale.",
|
|
771
|
+
),
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Expone un servicio local a internet mediante un tunel seguro."""
|
|
774
|
+
import time
|
|
775
|
+
|
|
776
|
+
port: int | None = None
|
|
777
|
+
if target and target.isdigit():
|
|
778
|
+
# `target` es texto porque tambien acepta el nombre de un servicio, asi
|
|
779
|
+
# que se pierde el `min`/`max` que traen los demas comandos. Sin esto,
|
|
780
|
+
# `stackhelx share 0` levantaba el cliente de tuneles contra 127.0.0.1:0.
|
|
781
|
+
try:
|
|
782
|
+
port = ports.check_port(int(target))
|
|
783
|
+
except ValueError as exc:
|
|
784
|
+
err.print(str(exc))
|
|
785
|
+
raise typer.Exit(1)
|
|
786
|
+
else:
|
|
787
|
+
try:
|
|
788
|
+
stack = detect.stack_for(Path.cwd())
|
|
789
|
+
except config.ConfigError as exc:
|
|
790
|
+
err.print(f"{exc}\nEspecifica el puerto a compartir: stackhelx share 3000")
|
|
791
|
+
raise typer.Exit(1)
|
|
792
|
+
|
|
793
|
+
if target and target in stack.services:
|
|
794
|
+
svc = stack.services[target]
|
|
795
|
+
if svc.port:
|
|
796
|
+
port = svc.port
|
|
797
|
+
else:
|
|
798
|
+
err.print(f"El servicio '{target}' no tiene un puerto fijo declarado.")
|
|
799
|
+
raise typer.Exit(1)
|
|
800
|
+
elif target:
|
|
801
|
+
err.print(f"Servicio o puerto '{target}' no encontrado en el stack.")
|
|
802
|
+
raise typer.Exit(1)
|
|
803
|
+
else:
|
|
804
|
+
ports_list = stack.ports()
|
|
805
|
+
if not ports_list:
|
|
806
|
+
err.print(f"{stack.path} no declara ningun puerto.")
|
|
807
|
+
raise typer.Exit(1)
|
|
808
|
+
port = ports_list[-1]
|
|
809
|
+
|
|
810
|
+
console.print(f"[bold cyan]Iniciando tunel hacia 127.0.0.1:{port}...[/]")
|
|
811
|
+
try:
|
|
812
|
+
tun = tunnel.start_tunnel(port, provider=provider)
|
|
813
|
+
except tunnel.TunnelError as exc:
|
|
814
|
+
err.print(f"[bold red]Error:[/] {exc}")
|
|
815
|
+
raise typer.Exit(1)
|
|
816
|
+
|
|
817
|
+
console.print(f"[bold green]Tunel activo![/] Proveedor: [bold]{tun.provider}[/]")
|
|
818
|
+
console.print(f"Local: [cyan]http://127.0.0.1:{port}[/]")
|
|
819
|
+
console.print(f"Publico: [bold underline green]{tun.url}[/]")
|
|
820
|
+
console.print("[dim]Presiona Ctrl-C para cerrar el tunel.[/]")
|
|
821
|
+
|
|
822
|
+
try:
|
|
823
|
+
while tun.proc.poll() is None:
|
|
824
|
+
time.sleep(0.5)
|
|
825
|
+
except KeyboardInterrupt:
|
|
826
|
+
pass
|
|
827
|
+
finally:
|
|
828
|
+
tun.stop()
|
|
829
|
+
console.print("\n[dim]Tunel cerrado.[/]")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@app.command("clean")
|
|
833
|
+
def clean_cmd(
|
|
834
|
+
solo: list[str] = typer.Option(
|
|
835
|
+
None,
|
|
836
|
+
"--solo",
|
|
837
|
+
"-s",
|
|
838
|
+
help=(
|
|
839
|
+
"Limpiar solo estas categorias: containers, images, networks, cache. "
|
|
840
|
+
"Repetible. Sin esto, las cuatro."
|
|
841
|
+
),
|
|
842
|
+
),
|
|
843
|
+
volumes: bool = typer.Option(
|
|
844
|
+
False,
|
|
845
|
+
"--volumes",
|
|
846
|
+
"-v",
|
|
847
|
+
help="Elimina tambien volumenes anonimos/huerfanos de Docker.",
|
|
848
|
+
),
|
|
849
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="No preguntar."),
|
|
850
|
+
) -> None:
|
|
851
|
+
"""Limpia contenedores parados, imagenes sin tag y recursos huerfanos de Docker."""
|
|
852
|
+
# `--solo` acota, `--volumes` suma. Son cosas distintas: una elige de lo que
|
|
853
|
+
# se regenera, la otra agrega lo que tiene datos adentro.
|
|
854
|
+
objetivos = list(solo) if solo else list(docker.DEFAULT_TARGETS)
|
|
855
|
+
desconocidos = [t for t in objetivos if t not in docker.DEFAULT_TARGETS]
|
|
856
|
+
if desconocidos:
|
|
857
|
+
err.print(
|
|
858
|
+
f"Categoria desconocida: {', '.join(desconocidos)}. "
|
|
859
|
+
f"Validas: {', '.join(docker.DEFAULT_TARGETS)}"
|
|
860
|
+
)
|
|
861
|
+
raise typer.Exit(1)
|
|
862
|
+
if volumes:
|
|
863
|
+
objetivos.append("volumes")
|
|
864
|
+
# Preguntando, como `free`. Es el unico comando de la herramienta que borra
|
|
865
|
+
# datos en vez de cerrar procesos, y era el unico que no preguntaba nada: el
|
|
866
|
+
# boton de la interfaz ya pedia dos clicks y este se ejecutaba en silencio.
|
|
867
|
+
if not yes:
|
|
868
|
+
tabla = docker.usage()
|
|
869
|
+
if tabla:
|
|
870
|
+
console.print(tabla)
|
|
871
|
+
console.print("Se borra: " + ", ".join(docker.ETIQUETAS[t] for t in objetivos if t != "volumes"))
|
|
872
|
+
if volumes:
|
|
873
|
+
console.print("[bold]Y los volumenes anonimos huerfanos, que tienen datos adentro.[/]")
|
|
874
|
+
if not typer.confirm("Seguir?", default=False):
|
|
875
|
+
console.print("Cancelado.")
|
|
876
|
+
return
|
|
877
|
+
|
|
878
|
+
console.print("[bold cyan]Ejecutando limpieza de recursos Docker...[/]")
|
|
879
|
+
ok, msg = docker.prune(objetivos)
|
|
880
|
+
if ok:
|
|
881
|
+
console.print(f"[bold green]Listo:[/] {msg}")
|
|
882
|
+
else:
|
|
883
|
+
err.print(f"[bold red]Error al limpiar Docker:[/] {msg}")
|
|
884
|
+
raise typer.Exit(1)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
@app.command("mcp")
|
|
888
|
+
def mcp_cmd(
|
|
889
|
+
show_config: bool = typer.Option(
|
|
890
|
+
False,
|
|
891
|
+
"--config",
|
|
892
|
+
"-c",
|
|
893
|
+
help="Muestra el bloque de configuracion JSON para Claude Desktop, Cursor o Antigravity.",
|
|
894
|
+
),
|
|
895
|
+
) -> None:
|
|
896
|
+
"""Inicia el servidor Model Context Protocol (MCP) sobre stdio para agentes de IA."""
|
|
897
|
+
if show_config:
|
|
898
|
+
cfg = {
|
|
899
|
+
"mcpServers": {
|
|
900
|
+
"stackhelx": {
|
|
901
|
+
"command": "stackhelx",
|
|
902
|
+
"args": ["mcp"],
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
console.print(json.dumps(cfg, indent=2))
|
|
907
|
+
return
|
|
908
|
+
mcp.serve_stdio()
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@app.command("version")
|
|
912
|
+
def version_cmd() -> None:
|
|
913
|
+
"""Muestra la version instalada."""
|
|
914
|
+
console.print(__version__)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@app.command("history")
|
|
918
|
+
def history_cmd(
|
|
919
|
+
target: str = typer.Argument(None),
|
|
920
|
+
limit: int = typer.Option(5, "--limit", "-n", help="Cantidad de arranques a mostrar"),
|
|
921
|
+
) -> None:
|
|
922
|
+
"""Muestra el historial de arranques del proyecto."""
|
|
923
|
+
if limit < 1 or limit > history.MAX_LIMIT:
|
|
924
|
+
err.print(f"[red]Error:[/] --limit debe ser entre 1 y {history.MAX_LIMIT}")
|
|
925
|
+
raise typer.Exit(1)
|
|
926
|
+
|
|
927
|
+
try:
|
|
928
|
+
path = (Path.cwd() / (target or "")).resolve()
|
|
929
|
+
stack = detect.stack_for(path)
|
|
930
|
+
pid = registry.project_id(stack.root)
|
|
931
|
+
except config.ConfigError as exc:
|
|
932
|
+
err.print(f"[red]Error:[/] {exc}")
|
|
933
|
+
raise typer.Exit(1)
|
|
934
|
+
|
|
935
|
+
runs = history.read(pid, limit=limit)
|
|
936
|
+
if not runs:
|
|
937
|
+
console.print(f"No hay historial para el proyecto [bold]{stack.name}[/]")
|
|
938
|
+
return
|
|
939
|
+
|
|
940
|
+
table = Table(title=f"Historial de arranques: {stack.name}")
|
|
941
|
+
table.add_column("Fecha")
|
|
942
|
+
table.add_column("Perfil")
|
|
943
|
+
table.add_column("Duración")
|
|
944
|
+
table.add_column("Resultado")
|
|
945
|
+
|
|
946
|
+
for r in reversed(runs):
|
|
947
|
+
fecha = r.get("timestamp", "").split("T")[0] + " " + r.get("timestamp", "T")[:16].split("T")[-1]
|
|
948
|
+
perfil = r.get("profile") or "-"
|
|
949
|
+
dur = f"{r.get('duration_s', 0)}s"
|
|
950
|
+
res = r.get("result", "unknown")
|
|
951
|
+
|
|
952
|
+
color = "green" if res == "running" else "red" if res == "error" else "yellow"
|
|
953
|
+
res_format = f"[{color}]{res}[/]"
|
|
954
|
+
if res == "error" and "error" in r:
|
|
955
|
+
res_format += f"\n[dim]{r['error']}[/]"
|
|
956
|
+
|
|
957
|
+
table.add_row(fecha, perfil, dur, res_format)
|
|
958
|
+
|
|
959
|
+
console.print(table)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@app.command("test-stack")
|
|
963
|
+
def test_stack_cmd(
|
|
964
|
+
target: str = typer.Argument(None, help="Ruta al proyecto o directorio"),
|
|
965
|
+
) -> None:
|
|
966
|
+
"""Valida la configuración del stack (puertos, dependencias, variables) sin levantar servicios."""
|
|
967
|
+
path = (Path.cwd() / (target or "")).resolve()
|
|
968
|
+
try:
|
|
969
|
+
stack = detect.stack_for(path)
|
|
970
|
+
services = stack.resolve()
|
|
971
|
+
except config.ConfigError as exc:
|
|
972
|
+
err.print(f"[bold red]Configuración inválida:[/] {exc}")
|
|
973
|
+
raise typer.Exit(1)
|
|
974
|
+
|
|
975
|
+
console.print(f"Validando stack [bold]{stack.name}[/] en [dim]{stack.root}[/]...")
|
|
976
|
+
console.print(f"[green]OK:[/] {len(services)} servicio(s) resueltos en orden topológico:")
|
|
977
|
+
for s in services:
|
|
978
|
+
deps = f" (espera a: {', '.join(s.needs)})" if s.needs else ""
|
|
979
|
+
port_info = f" -> puerto {s.port}" if s.port else f" ({s.ready})"
|
|
980
|
+
console.print(f" - [cyan]{s.name}[/]: [dim]{s.command}[/]{port_info}{deps}")
|
|
981
|
+
|
|
982
|
+
# Verificar estado de puertos
|
|
983
|
+
declared_ports = stack.ports()
|
|
984
|
+
if declared_ports:
|
|
985
|
+
occupied = [p for p in declared_ports if not ports.is_free(p)]
|
|
986
|
+
if occupied:
|
|
987
|
+
console.print(f"[yellow]Aviso:[/] Puertos actualmente en uso: {', '.join(map(str, occupied))}")
|
|
988
|
+
else:
|
|
989
|
+
console.print("[green]OK:[/] Todos los puertos declarados están libres")
|
|
990
|
+
|
|
991
|
+
# Sin el `✓` que habia aca: no existe en cp1252, o sea la pagina de
|
|
992
|
+
# codigos con la que sale la consola de Windows, y el comando entero
|
|
993
|
+
# terminaba en UnicodeEncodeError despues de haber validado bien. Los
|
|
994
|
+
# acentos si entran en cp1252, por eso se quedan.
|
|
995
|
+
console.print("\n[bold green]Stack validado con éxito.[/]")
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
@app.command("logs")
|
|
999
|
+
def logs_cmd(
|
|
1000
|
+
target: str = typer.Argument(None, help="Ruta al proyecto"),
|
|
1001
|
+
service: str = typer.Option(None, "--service", "-s", help="Filtrar por nombre de servicio"),
|
|
1002
|
+
follow: bool = typer.Option(False, "--follow", "-f", help="Seguir logs en tiempo real"),
|
|
1003
|
+
server_port: int = typer.Option(7666, "--port", "-p", help="Puerto del servidor de StackHelx"),
|
|
1004
|
+
) -> None:
|
|
1005
|
+
"""Muestra o sigue los logs del proyecto en ejecución en StackHelx."""
|
|
1006
|
+
import time
|
|
1007
|
+
import urllib.request
|
|
1008
|
+
|
|
1009
|
+
path = (Path.cwd() / (target or "")).resolve()
|
|
1010
|
+
try:
|
|
1011
|
+
stack = detect.stack_for(path)
|
|
1012
|
+
pid = registry.project_id(stack.root)
|
|
1013
|
+
except config.ConfigError as exc:
|
|
1014
|
+
err.print(f"[red]Error:[/] {exc}")
|
|
1015
|
+
raise typer.Exit(1)
|
|
1016
|
+
|
|
1017
|
+
token = registry.token()
|
|
1018
|
+
base_url = f"http://127.0.0.1:{server_port}"
|
|
1019
|
+
|
|
1020
|
+
seq = 0
|
|
1021
|
+
retries = 0
|
|
1022
|
+
while True:
|
|
1023
|
+
req = urllib.request.Request(
|
|
1024
|
+
f"{base_url}/api/projects/{pid}/logs?since={seq}",
|
|
1025
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
1026
|
+
)
|
|
1027
|
+
try:
|
|
1028
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
1029
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1030
|
+
retries = 0
|
|
1031
|
+
except Exception:
|
|
1032
|
+
if follow and retries < 3:
|
|
1033
|
+
retries += 1
|
|
1034
|
+
time.sleep(1.0)
|
|
1035
|
+
continue
|
|
1036
|
+
err.print(
|
|
1037
|
+
f"[yellow]No se pudo conectar con StackHelx en {base_url}. Asegúrate de que `stackhelx serve` está corriendo.[/]"
|
|
1038
|
+
)
|
|
1039
|
+
raise typer.Exit(1)
|
|
1040
|
+
|
|
1041
|
+
lines = data.get("lines", [])
|
|
1042
|
+
for item in lines:
|
|
1043
|
+
text = item.get("text", "")
|
|
1044
|
+
seq = max(seq, item.get("seq", seq))
|
|
1045
|
+
if not service or service in text:
|
|
1046
|
+
console.print(text)
|
|
1047
|
+
|
|
1048
|
+
if not follow:
|
|
1049
|
+
if not lines and seq == 0:
|
|
1050
|
+
console.print(f"[dim]No hay logs disponibles para {stack.name}.[/]")
|
|
1051
|
+
break
|
|
1052
|
+
|
|
1053
|
+
time.sleep(0.5)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
@app.command("stats")
|
|
1057
|
+
@app.command("top")
|
|
1058
|
+
def stats_cmd(
|
|
1059
|
+
target: str = typer.Argument(None, help="Ruta al proyecto"),
|
|
1060
|
+
server_port: int = typer.Option(7666, "--port", "-p", help="Puerto del servidor de StackHelx"),
|
|
1061
|
+
) -> None:
|
|
1062
|
+
"""Muestra el uso de CPU y memoria de los servicios en ejecución."""
|
|
1063
|
+
import urllib.request
|
|
1064
|
+
|
|
1065
|
+
path = (Path.cwd() / (target or "")).resolve()
|
|
1066
|
+
try:
|
|
1067
|
+
stack = detect.stack_for(path)
|
|
1068
|
+
pid = registry.project_id(stack.root)
|
|
1069
|
+
except config.ConfigError as exc:
|
|
1070
|
+
err.print(f"[red]Error:[/] {exc}")
|
|
1071
|
+
raise typer.Exit(1)
|
|
1072
|
+
|
|
1073
|
+
token = registry.token()
|
|
1074
|
+
base_url = f"http://127.0.0.1:{server_port}"
|
|
1075
|
+
req = urllib.request.Request(
|
|
1076
|
+
f"{base_url}/api/projects/{pid}/metrics",
|
|
1077
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
1078
|
+
)
|
|
1079
|
+
try:
|
|
1080
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
1081
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1082
|
+
except Exception:
|
|
1083
|
+
err.print(
|
|
1084
|
+
f"[yellow]No se pudo conectar con StackHelx en {base_url}. Asegúrate de que `stackhelx serve` está corriendo.[/]"
|
|
1085
|
+
)
|
|
1086
|
+
raise typer.Exit(1)
|
|
1087
|
+
|
|
1088
|
+
metrics = data.get("metrics", {})
|
|
1089
|
+
if not metrics:
|
|
1090
|
+
console.print(f"[dim]No hay servicios activos en {stack.name}.[/]")
|
|
1091
|
+
return
|
|
1092
|
+
|
|
1093
|
+
table = Table(title=f"Métricas en tiempo real: {stack.name}")
|
|
1094
|
+
table.add_column("Servicio", style="cyan")
|
|
1095
|
+
table.add_column("PID", style="dim")
|
|
1096
|
+
table.add_column("CPU %", justify="right")
|
|
1097
|
+
table.add_column("Memoria (MB)", justify="right")
|
|
1098
|
+
|
|
1099
|
+
for name, s in metrics.items():
|
|
1100
|
+
cpu = f"{s.get('cpu_percent', 0.0)}%"
|
|
1101
|
+
mem = f"{s.get('memory_mb', 0.0)} MB"
|
|
1102
|
+
table.add_row(name, str(s.get("pid", "-")), cpu, mem)
|
|
1103
|
+
|
|
1104
|
+
console.print(table)
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@app.command("mcp-status")
|
|
1108
|
+
def mcp_status_cmd(
|
|
1109
|
+
server_port: int = typer.Option(7666, "--port", "-p", help="Puerto del servidor de StackHelx"),
|
|
1110
|
+
) -> None:
|
|
1111
|
+
"""Muestra la telemetría y estado de llamadas MCP para agentes IA."""
|
|
1112
|
+
import urllib.request
|
|
1113
|
+
|
|
1114
|
+
token = registry.token()
|
|
1115
|
+
base_url = f"http://127.0.0.1:{server_port}"
|
|
1116
|
+
req = urllib.request.Request(
|
|
1117
|
+
f"{base_url}/api/mcp/activity",
|
|
1118
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
1119
|
+
)
|
|
1120
|
+
try:
|
|
1121
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
1122
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
1123
|
+
except Exception:
|
|
1124
|
+
data = mcp.get_telemetry()
|
|
1125
|
+
|
|
1126
|
+
total = data.get("total_calls", 0)
|
|
1127
|
+
rate = data.get("active_rate_per_min", 0)
|
|
1128
|
+
max_rate = data.get("rate_limit_max", 30)
|
|
1129
|
+
console.print(
|
|
1130
|
+
f"[bold]Servidor MCP StackHelx[/] · Llamadas: [cyan]{total}[/] · Cuota: [green]{rate}/{max_rate}[/] req/min"
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
by_tool = data.get("by_tool", {})
|
|
1134
|
+
if by_tool:
|
|
1135
|
+
tool_table = Table(title="Invocaciones por herramienta MCP")
|
|
1136
|
+
tool_table.add_column("Herramienta", style="cyan")
|
|
1137
|
+
tool_table.add_column("Llamadas", justify="right")
|
|
1138
|
+
for tool, count in sorted(by_tool.items(), key=lambda x: -x[1]):
|
|
1139
|
+
tool_table.add_row(tool, str(count))
|
|
1140
|
+
console.print(tool_table)
|
|
1141
|
+
|
|
1142
|
+
events = data.get("recent_events", [])
|
|
1143
|
+
if events:
|
|
1144
|
+
ev_table = Table(title="Invocaciones recientes (últimas 10)")
|
|
1145
|
+
ev_table.add_column("Timestamp", style="dim")
|
|
1146
|
+
ev_table.add_column("Herramienta", style="cyan")
|
|
1147
|
+
ev_table.add_column("Duración", justify="right")
|
|
1148
|
+
ev_table.add_column("Estado")
|
|
1149
|
+
for ev in events[:10]:
|
|
1150
|
+
st = ev.get("status", "ok")
|
|
1151
|
+
st_style = (
|
|
1152
|
+
"[green]ok[/]"
|
|
1153
|
+
if st == "ok"
|
|
1154
|
+
else (f"[yellow]{st}[/]" if st == "rate_limited" else f"[red]{st}[/]")
|
|
1155
|
+
)
|
|
1156
|
+
dur = f"{ev.get('duration_ms', 0):.1f} ms"
|
|
1157
|
+
ts = ev.get("timestamp", "").replace("T", " ")[:19]
|
|
1158
|
+
ev_table.add_row(ts, ev.get("tool", "-"), dur, st_style)
|
|
1159
|
+
console.print(ev_table)
|
|
1160
|
+
elif not by_tool:
|
|
1161
|
+
console.print("[dim]Sin actividad reciente de agentes MCP.[/]")
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
if __name__ == "__main__":
|
|
1165
|
+
app()
|