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/detect.py
ADDED
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
"""Deteccion de servicios en un proyecto sin stack.yaml.
|
|
2
|
+
|
|
3
|
+
Mira la raiz del proyecto y arma el mismo `Stack` congelado que produce
|
|
4
|
+
`config.load`, para que `runner`, `server` y `cli` no distingan el origen.
|
|
5
|
+
|
|
6
|
+
Los detectores corren en el orden en que hay que arrancarlos: contenedores,
|
|
7
|
+
backend (Python, Go, Rust, Ruby, PHP, .NET), frontend. Cada uno devuelve los
|
|
8
|
+
reconoce, o una lista vacia. El compose devuelve uno por contenedor, para que
|
|
9
|
+
cada uno tenga su puerto y su estado propios.
|
|
10
|
+
|
|
11
|
+
Ninguno detecta un proyecto que no sirva nada por un puerto. Una libreria o una
|
|
12
|
+
herramienta de linea de comandos entraria como servicio y el arranque esperaria
|
|
13
|
+
un puerto que nunca abre, hasta el timeout.
|
|
14
|
+
|
|
15
|
+
Ningun detector adivina el puerto de un proceso que no lo declara: compose si lo
|
|
16
|
+
declara y se lee del archivo, y los otros dos usan `ready: listen`, que descubre
|
|
17
|
+
el puerto real del proceso ya arrancado. Ver el spec en docs/.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import functools
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
from dataclasses import replace
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
try: # Python 3.11+
|
|
33
|
+
import tomllib
|
|
34
|
+
except ModuleNotFoundError: # pragma: no cover - solo en Python 3.10
|
|
35
|
+
import tomli as tomllib
|
|
36
|
+
|
|
37
|
+
from .config import CONFIG_NAMES, ConfigError, Service, Stack, find, load
|
|
38
|
+
|
|
39
|
+
COMPOSE_NAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
|
40
|
+
|
|
41
|
+
# lockfile -> gestor. npm es el fallback: package-lock.json no es obligatorio.
|
|
42
|
+
LOCKFILES = {
|
|
43
|
+
"pnpm-lock.yaml": "pnpm",
|
|
44
|
+
"yarn.lock": "yarn",
|
|
45
|
+
"bun.lockb": "bun",
|
|
46
|
+
"bun.lock": "bun",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Donde vive la app ASGI, en orden de preferencia.
|
|
50
|
+
ASGI_MODULES = ("main.py", "app.py", "asgi.py", "src/main.py", "api/main.py", "app/main.py")
|
|
51
|
+
|
|
52
|
+
# Scripts que levantan un servidor, en orden de preferencia. `start:dev` es el
|
|
53
|
+
# modo watch de Nest, que no tiene `dev`.
|
|
54
|
+
NODE_SCRIPTS = ("dev", "start:dev", "serve", "start")
|
|
55
|
+
|
|
56
|
+
# Subcarpetas donde vive el frontend de un monorepo, y los grupos cuyos hijos se
|
|
57
|
+
# revisan uno por uno.
|
|
58
|
+
NODE_DIRS = ("frontend", "web", "client", "ui", "front", "site")
|
|
59
|
+
# Sirven para Python, Go y Rust: el nombre de la carpeta no dice el lenguaje.
|
|
60
|
+
BACKEND_DIRS = ("backend", "api", "server")
|
|
61
|
+
WORKSPACE_DIRS = ("apps", "packages", "services")
|
|
62
|
+
|
|
63
|
+
# Dependencias que delatan un servidor de desarrollo de verdad.
|
|
64
|
+
DEV_SERVERS = (
|
|
65
|
+
"vite",
|
|
66
|
+
"next",
|
|
67
|
+
"nuxt",
|
|
68
|
+
"astro",
|
|
69
|
+
"@remix-run",
|
|
70
|
+
"react-scripts",
|
|
71
|
+
"@angular/cli",
|
|
72
|
+
"webpack-dev-server",
|
|
73
|
+
"@nestjs/cli",
|
|
74
|
+
"expo",
|
|
75
|
+
"@sveltejs/kit",
|
|
76
|
+
"parcel",
|
|
77
|
+
"gatsby",
|
|
78
|
+
"nodemon",
|
|
79
|
+
"ts-node-dev",
|
|
80
|
+
"hono",
|
|
81
|
+
"fastify",
|
|
82
|
+
"express",
|
|
83
|
+
"nitro",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Frameworks web de Rust. No hay servidor HTTP en la stdlib, asi que si el
|
|
87
|
+
# Cargo.toml no nombra a ninguno, el binario no sirve nada por un puerto.
|
|
88
|
+
#
|
|
89
|
+
# `hyper` estaba en esta lista y salio: es la base de casi todo el HTTP de Rust
|
|
90
|
+
# y la mitad de las veces entra como cliente. Un CLI que descarga algo lo
|
|
91
|
+
# declara igual que un servidor, y detectarlo dejaba al arranque esperando un
|
|
92
|
+
# puerto que nunca abre. Los que quedan son frameworks de servidor y nada mas.
|
|
93
|
+
RUST_SERVERS = (
|
|
94
|
+
"axum",
|
|
95
|
+
"actix-web",
|
|
96
|
+
"rocket",
|
|
97
|
+
"warp",
|
|
98
|
+
"tide",
|
|
99
|
+
"poem",
|
|
100
|
+
"salvo",
|
|
101
|
+
"tonic",
|
|
102
|
+
"trillium",
|
|
103
|
+
"gotham",
|
|
104
|
+
"volo-http",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Frameworks web de Go. A diferencia de Rust, esta lista no alcanza: net/http es
|
|
108
|
+
# stdlib y un servidor escrito con ella no deja rastro en go.mod. Por eso ademas
|
|
109
|
+
# se busca la llamada que lo delata en el fuente.
|
|
110
|
+
GO_SERVERS = ("gin-gonic/gin", "labstack/echo", "gofiber/fiber", "go-chi/chi", "gorilla/mux")
|
|
111
|
+
GO_SERVES = ("ListenAndServe", "http.Serve(")
|
|
112
|
+
|
|
113
|
+
# Donde buscar el paquete main de un proyecto Go.
|
|
114
|
+
GO_MAINS = ("main.go", "cmd/server/main.go", "cmd/api/main.go", "cmd/app/main.go")
|
|
115
|
+
|
|
116
|
+
# Frameworks JVM que sirven por un puerto, con su tarea en cada build system:
|
|
117
|
+
# (marcas en el archivo del build, goal de Maven, tarea de Gradle). `None` donde
|
|
118
|
+
# el framework no tiene un camino usable con esa herramienta.
|
|
119
|
+
#
|
|
120
|
+
# `spring-boot-starter-web` cubre tambien `-webflux`, que lo contiene como
|
|
121
|
+
# prefijo. Y no cubre `spring-boot-starter` a secas, que es lo que importa: esa
|
|
122
|
+
# es una app de Spring sin servlet container (una tarea batch, un consumidor de
|
|
123
|
+
# colas) y no abre ningun puerto.
|
|
124
|
+
JVM_SERVERS = (
|
|
125
|
+
(("spring-boot-starter-web",), "spring-boot:run", "bootRun"),
|
|
126
|
+
(("quarkus-maven-plugin", "io.quarkus", "quarkus-gradle-plugin"), "quarkus:dev", "quarkusDev"),
|
|
127
|
+
(("micronaut-http-server",), "mn:run", "run"),
|
|
128
|
+
(("ktor-server",), None, "run"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# `{:phoenix, "~> 1.7"}` y no un `"phoenix" in texto`. Una libreria de
|
|
132
|
+
# componentes declara `phoenix_html` o `phoenix_live_view` sin ser una
|
|
133
|
+
# aplicacion: no tiene endpoint, y `mix phx.server` ahi falla. La coma es lo
|
|
134
|
+
# unico que separa un caso del otro.
|
|
135
|
+
PHOENIX_DEP = re.compile(r"\{\s*:phoenix\s*,")
|
|
136
|
+
|
|
137
|
+
# Que delata un proyecto de Bun. El lockfile ya estaba en LOCKFILES, pero ahi
|
|
138
|
+
# sirve para elegir el gestor de paquetes de un proyecto Node; aca dice que el
|
|
139
|
+
# runtime es Bun, que es otra pregunta.
|
|
140
|
+
BUN_MARKERS = ("bunfig.toml", "bun.lockb", "bun.lock")
|
|
141
|
+
|
|
142
|
+
# Lo que Bun ejecuta directo, en orden de preferencia.
|
|
143
|
+
BUN_ENTRIES = (
|
|
144
|
+
"main.ts", "app.ts", "index.ts", "server.ts",
|
|
145
|
+
"src/main.ts", "src/app.ts", "src/index.ts", "src/server.ts",
|
|
146
|
+
"index.js", "server.js",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Frameworks HTTP del ecosistema. Con cualquiera de estos el fuente no nombra a
|
|
150
|
+
# `Bun.serve`, asi que la dependencia es la unica senal.
|
|
151
|
+
BUN_SERVERS = ("hono", "elysia", "@elysiajs/", "@hono/", "bun-router")
|
|
152
|
+
BUN_SERVES = "Bun.serve("
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def stack_for(root: Path) -> Stack:
|
|
156
|
+
"""Stack del proyecto: el archivo si existe, la deteccion si no.
|
|
157
|
+
|
|
158
|
+
El archivo se busca hacia arriba como siempre, para poder correr desde un
|
|
159
|
+
subdirectorio. La deteccion solo mira `root`.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
path = find(root)
|
|
163
|
+
except ConfigError:
|
|
164
|
+
path = None
|
|
165
|
+
if path is not None:
|
|
166
|
+
# Un archivo invalido es un error, no una excusa para detectar por atras.
|
|
167
|
+
return load(path)
|
|
168
|
+
|
|
169
|
+
found = detect(root)
|
|
170
|
+
if found is None:
|
|
171
|
+
raise ConfigError(
|
|
172
|
+
f"{root} no tiene stack.yaml y no se detecto nada conocido "
|
|
173
|
+
"(compose, package.json, manage.py)"
|
|
174
|
+
)
|
|
175
|
+
return found
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def detect(root: Path) -> Stack | None:
|
|
179
|
+
"""Servicios detectados en root, o None si no se reconoce nada."""
|
|
180
|
+
root = root.resolve()
|
|
181
|
+
services: dict[str, Service] = {}
|
|
182
|
+
previous: tuple[str, ...] = ()
|
|
183
|
+
|
|
184
|
+
opcionales = _compose_profiles(root)
|
|
185
|
+
|
|
186
|
+
# `_bun` va ultimo, y no es un detalle de estilo: el primero gana (ver abajo),
|
|
187
|
+
# y un proyecto con `package.json` mas `bun.lock` tiene que salir por `_node`,
|
|
188
|
+
# que es el unico que sabe leer los scripts. `_bun` atrapa lo que sobra.
|
|
189
|
+
for detector in (
|
|
190
|
+
_compose, _python, _go, _rust, _ruby, _elixir, _php, _dotnet, _jvm, _deno, _node, _bun
|
|
191
|
+
):
|
|
192
|
+
group = []
|
|
193
|
+
for service in detector(root):
|
|
194
|
+
if service.name in services:
|
|
195
|
+
continue # el primero gana: el contenedor le come el nombre al local
|
|
196
|
+
# Los contenedores traen sus propias dependencias del compose. Los
|
|
197
|
+
# demas heredan la cadena: el frontend espera al backend, y el
|
|
198
|
+
# backend a los contenedores.
|
|
199
|
+
wired = replace(service, needs=service.needs or previous)
|
|
200
|
+
services[wired.name] = wired
|
|
201
|
+
# Un contenedor con perfil no entra en la cadena heredada: si el
|
|
202
|
+
# frontend lo necesitara, el orden topologico lo volveria a arrastrar
|
|
203
|
+
# al arranque por defecto y el perfil no serviria de nada.
|
|
204
|
+
if wired.name not in opcionales:
|
|
205
|
+
group.append(wired.name)
|
|
206
|
+
if group:
|
|
207
|
+
previous = tuple(group)
|
|
208
|
+
|
|
209
|
+
if not services:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
stack = Stack(
|
|
213
|
+
name=root.name,
|
|
214
|
+
root=root,
|
|
215
|
+
path=root,
|
|
216
|
+
services={
|
|
217
|
+
name: replace(service, needs=tuple(d for d in service.needs if d in services))
|
|
218
|
+
for name, service in services.items()
|
|
219
|
+
},
|
|
220
|
+
profiles=_profiles_for(services, opcionales),
|
|
221
|
+
detected=True,
|
|
222
|
+
# Solo cuando hay algo que dejar afuera: sin perfiles, `None` sigue
|
|
223
|
+
# queriendo decir "todo", que es lo que era antes de existir este campo.
|
|
224
|
+
default=tuple(n for n in services if n not in opcionales) if opcionales else None,
|
|
225
|
+
)
|
|
226
|
+
stack.resolve() # los ciclos fallan al detectar, no a mitad del arranque
|
|
227
|
+
return stack
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _profiles_for(
|
|
231
|
+
services: dict[str, Service], opcionales: dict[str, tuple[str, ...]]
|
|
232
|
+
) -> dict[str, tuple[str, ...]]:
|
|
233
|
+
"""Un perfil de StackHelx por cada perfil declarado en el compose.
|
|
234
|
+
|
|
235
|
+
Pedir un perfil arranca lo de siempre **mas** los contenedores de ese
|
|
236
|
+
perfil, que es lo que hace `docker compose --profile X up`. Un perfil que
|
|
237
|
+
arrancara solo sus propios contenedores dejaria al resto del stack afuera y
|
|
238
|
+
no es lo que nadie quiso decir.
|
|
239
|
+
"""
|
|
240
|
+
base = tuple(n for n in services if n not in opcionales)
|
|
241
|
+
nombres = {p for perfiles in opcionales.values() for p in perfiles}
|
|
242
|
+
return {
|
|
243
|
+
nombre: base + tuple(n for n, perfiles in opcionales.items() if nombre in perfiles)
|
|
244
|
+
for nombre in sorted(nombres)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def freeze(root: Path) -> Path:
|
|
249
|
+
"""Escribe lo detectado como stack.yaml. Devuelve el archivo escrito.
|
|
250
|
+
|
|
251
|
+
Vive aca y no en `cli` porque la interfaz ofrece lo mismo, y `CLAUDE.md`
|
|
252
|
+
pide que el CLI y el servidor sean dos consumidores de la misma funcion.
|
|
253
|
+
"""
|
|
254
|
+
target = root / CONFIG_NAMES[0]
|
|
255
|
+
if any((root / name).is_file() for name in CONFIG_NAMES):
|
|
256
|
+
raise ConfigError(f"{root} ya tiene un stack.yaml. No se sobreescribe.")
|
|
257
|
+
|
|
258
|
+
stack = detect(root)
|
|
259
|
+
if stack is None:
|
|
260
|
+
raise ConfigError(
|
|
261
|
+
f"No se detecto nada conocido en {root} (compose, package.json, manage.py)"
|
|
262
|
+
)
|
|
263
|
+
target.write_text(to_yaml(stack), encoding="utf-8")
|
|
264
|
+
return target
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def to_yaml(stack: Stack) -> str:
|
|
268
|
+
"""Serializa un stack detectado al formato de stack.yaml."""
|
|
269
|
+
services = {}
|
|
270
|
+
for service in stack.services.values():
|
|
271
|
+
spec: dict[str, object] = {"command": service.command}
|
|
272
|
+
if service.cwd != stack.root:
|
|
273
|
+
spec["cwd"] = service.cwd.relative_to(stack.root).as_posix()
|
|
274
|
+
if service.port:
|
|
275
|
+
spec["port"] = service.port
|
|
276
|
+
spec["ready"] = service.ready
|
|
277
|
+
if service.needs:
|
|
278
|
+
spec["needs"] = list(service.needs)
|
|
279
|
+
if service.detached:
|
|
280
|
+
spec["detached"] = True
|
|
281
|
+
if service.stop:
|
|
282
|
+
spec["stop"] = service.stop
|
|
283
|
+
services[service.name] = spec
|
|
284
|
+
|
|
285
|
+
# Congelar tiene que ser fiel: sin estas dos claves, un compose con
|
|
286
|
+
# `profiles:` volveria a cargarse arrancando lo que deja apagado.
|
|
287
|
+
document: dict[str, object] = {"name": stack.name, "services": services}
|
|
288
|
+
if stack.default is not None:
|
|
289
|
+
document["default"] = list(stack.default)
|
|
290
|
+
if stack.profiles:
|
|
291
|
+
document["profiles"] = {name: list(members) for name, members in stack.profiles.items()}
|
|
292
|
+
|
|
293
|
+
return yaml.safe_dump(document, sort_keys=False, allow_unicode=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# detectores ---------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _compose_services(root: Path) -> dict | None:
|
|
300
|
+
"""El mapa `services` del compose. None si no hay compose, {} si es ilegible.
|
|
301
|
+
|
|
302
|
+
Lo leen dos funciones (los contenedores y sus perfiles) y no queria dos
|
|
303
|
+
copias de la busqueda del archivo: divergen en el primer nombre nuevo.
|
|
304
|
+
"""
|
|
305
|
+
path = next((root / name for name in COMPOSE_NAMES if (root / name).is_file()), None)
|
|
306
|
+
if path is None:
|
|
307
|
+
return None
|
|
308
|
+
try:
|
|
309
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
310
|
+
except (OSError, yaml.YAMLError, UnicodeDecodeError):
|
|
311
|
+
raw = None
|
|
312
|
+
declared = raw.get("services") if isinstance(raw, dict) else None
|
|
313
|
+
return declared if isinstance(declared, dict) else {}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _compose(root: Path) -> list[Service]:
|
|
317
|
+
"""Un servicio por contenedor, no uno solo llamado "docker".
|
|
318
|
+
|
|
319
|
+
`docker compose up -d <nombre>` arranca ese contenedor y sus dependencias, y
|
|
320
|
+
es idempotente. Asi cada contenedor tiene su puerto, su estado y su link en
|
|
321
|
+
la interfaz, en vez de esconderse detras de un unico bloque opaco.
|
|
322
|
+
"""
|
|
323
|
+
declared = _compose_services(root)
|
|
324
|
+
if declared is None:
|
|
325
|
+
return []
|
|
326
|
+
if not declared:
|
|
327
|
+
# Compose ilegible o sin servicios: se arranca entero y sin puertos.
|
|
328
|
+
return [
|
|
329
|
+
_container("docker", "docker compose up -d", root, None, (), "docker compose stop")
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
env = _dotenv(root)
|
|
333
|
+
names = {str(name) for name in declared}
|
|
334
|
+
found = []
|
|
335
|
+
for name, spec in declared.items():
|
|
336
|
+
name = str(name)
|
|
337
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
338
|
+
needs = tuple(dep for dep in _depends_on(spec) if dep in names and dep != name)
|
|
339
|
+
found.append(
|
|
340
|
+
_container(
|
|
341
|
+
name,
|
|
342
|
+
f"docker compose up -d {name}",
|
|
343
|
+
root,
|
|
344
|
+
_first_port(spec, env),
|
|
345
|
+
needs,
|
|
346
|
+
f"docker compose stop {name}",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
return found
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _container(
|
|
353
|
+
name: str, command: str, root: Path, port: int | None, needs: tuple[str, ...], stop: str
|
|
354
|
+
):
|
|
355
|
+
return Service(
|
|
356
|
+
name=name,
|
|
357
|
+
command=command,
|
|
358
|
+
cwd=root,
|
|
359
|
+
port=port,
|
|
360
|
+
# Con puerto publicado se espera a que acepte conexiones; sin el, `up -d`
|
|
361
|
+
# ya termino y no hay nada mas que mirar desde afuera.
|
|
362
|
+
ready="port" if port else "none",
|
|
363
|
+
needs=needs,
|
|
364
|
+
env={},
|
|
365
|
+
detached=True,
|
|
366
|
+
stop=stop, # el contenedor no muere con el cliente que lo arranco
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _compose_profiles(root: Path) -> dict[str, tuple[str, ...]]:
|
|
371
|
+
"""Perfiles declarados por cada contenedor: nombre -> perfiles a los que pertenece.
|
|
372
|
+
|
|
373
|
+
Ojo con la semantica, que esta invertida respecto de la de StackHelx: en
|
|
374
|
+
compose, un servicio con `profiles:` queda **excluido** por defecto y solo
|
|
375
|
+
entra cuando pedis uno de sus perfiles. En StackHelx un perfil es la lista
|
|
376
|
+
de servicios a arrancar. Traducir uno al otro es el trabajo de `detect`,
|
|
377
|
+
aca abajo; mapearlos directo arrancaria lo que compose deja apagado a
|
|
378
|
+
proposito.
|
|
379
|
+
"""
|
|
380
|
+
declared = _compose_services(root) or {}
|
|
381
|
+
found = {}
|
|
382
|
+
for name, spec in declared.items():
|
|
383
|
+
value = spec.get("profiles") if isinstance(spec, dict) else None
|
|
384
|
+
# Viene como lista, o como string suelto si hay uno solo.
|
|
385
|
+
if isinstance(value, str):
|
|
386
|
+
value = [value]
|
|
387
|
+
if isinstance(value, list):
|
|
388
|
+
perfiles = tuple(str(p) for p in value if isinstance(p, (str, int)))
|
|
389
|
+
if perfiles:
|
|
390
|
+
found[str(name)] = perfiles
|
|
391
|
+
return found
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _depends_on(spec: dict) -> tuple[str, ...]:
|
|
395
|
+
"""`depends_on` en sus dos formas: lista de nombres, o mapa con condiciones."""
|
|
396
|
+
value = spec.get("depends_on")
|
|
397
|
+
if isinstance(value, dict):
|
|
398
|
+
return tuple(str(name) for name in value)
|
|
399
|
+
if isinstance(value, list):
|
|
400
|
+
return tuple(str(name) for name in value if isinstance(name, (str, int)))
|
|
401
|
+
return ()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _first_port(spec: dict, env: dict[str, str]) -> int | None:
|
|
405
|
+
for entry in spec.get("ports") or ():
|
|
406
|
+
port = _published(entry, env)
|
|
407
|
+
if port is not None:
|
|
408
|
+
return port
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _published(entry: object, env: dict[str, str]) -> int | None:
|
|
413
|
+
"""Puerto del host en una entrada de `ports:`.
|
|
414
|
+
|
|
415
|
+
Las formas son "8080:80", "127.0.0.1:8080:80", 8080 y {published: 8080}, con
|
|
416
|
+
o sin `${VAR:-default}` adentro. Se descartan los rangos ("8000-8010:80") y
|
|
417
|
+
las entradas de un solo puerto ("80" publica en un puerto del host al azar):
|
|
418
|
+
en los dos casos no hay un puerto fijo que mirar.
|
|
419
|
+
"""
|
|
420
|
+
if isinstance(entry, dict):
|
|
421
|
+
return _valid(_interpolate(entry.get("published"), env))
|
|
422
|
+
if not isinstance(entry, str):
|
|
423
|
+
return None
|
|
424
|
+
pieces = _interpolate(entry, env).split(":")
|
|
425
|
+
if len(pieces) < 2:
|
|
426
|
+
return None
|
|
427
|
+
return _valid(pieces[-2])
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ponytail: solo la forma con llaves. `$VAR` a secas tambien es valido en compose
|
|
431
|
+
# y no se resuelve; el puerto queda desconocido, que es mejor que inventarlo.
|
|
432
|
+
VARIABLE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _interpolate(value: object, env: dict[str, str]) -> object:
|
|
436
|
+
if not isinstance(value, str):
|
|
437
|
+
return value
|
|
438
|
+
|
|
439
|
+
def resolve(match: re.Match) -> str:
|
|
440
|
+
# Mismo orden que compose: entorno del shell, .env, y por ultimo el
|
|
441
|
+
# default de la propia expresion.
|
|
442
|
+
found = os.environ.get(match.group(1)) or env.get(match.group(1))
|
|
443
|
+
if found:
|
|
444
|
+
return found
|
|
445
|
+
return match.group(2) if match.group(2) is not None else match.group(0)
|
|
446
|
+
|
|
447
|
+
return VARIABLE.sub(resolve, value)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _dotenv(root: Path) -> dict[str, str]:
|
|
451
|
+
"""Variables de `.env`, y solo para resolver puertos del compose.
|
|
452
|
+
|
|
453
|
+
Compose lo lee, asi que ignorarlo daria puertos equivocados justo en los
|
|
454
|
+
proyectos que parametrizan el puerto del frontend. Los valores no se
|
|
455
|
+
loguean ni se pasan a ningun proceso.
|
|
456
|
+
"""
|
|
457
|
+
values = {}
|
|
458
|
+
for line in _read(root / ".env").splitlines():
|
|
459
|
+
line = line.strip()
|
|
460
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
461
|
+
continue
|
|
462
|
+
key, _, value = line.partition("=")
|
|
463
|
+
values[key.strip()] = value.strip().strip("\"'")
|
|
464
|
+
return values
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _valid(value: object) -> int | None:
|
|
468
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
469
|
+
return None
|
|
470
|
+
try:
|
|
471
|
+
port = int(value)
|
|
472
|
+
except ValueError:
|
|
473
|
+
return None
|
|
474
|
+
return port if 1 <= port <= 65535 else None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _python(root: Path) -> list[Service]:
|
|
478
|
+
at_root = _python_at(root, "api")
|
|
479
|
+
if at_root is not None:
|
|
480
|
+
# Igual que en Node: si la raiz es el proyecto, no se baja una vuelta.
|
|
481
|
+
return [at_root]
|
|
482
|
+
|
|
483
|
+
found = []
|
|
484
|
+
for path in _subprojects(root, BACKEND_DIRS):
|
|
485
|
+
service = _python_at(path, path.name)
|
|
486
|
+
if service is not None:
|
|
487
|
+
found.append(service)
|
|
488
|
+
return found
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _python_at(path: Path, name: str) -> Service | None:
|
|
492
|
+
pyproject_text = _read(path / "pyproject.toml")
|
|
493
|
+
has_uv = (path / "uv.lock").is_file() or bool(re.search(r"\[tool\.uv(\]|\.)", pyproject_text))
|
|
494
|
+
prefix = "uv run " if has_uv else ""
|
|
495
|
+
|
|
496
|
+
if (path / "manage.py").is_file():
|
|
497
|
+
return _served(name, f"{prefix}python manage.py runserver", path)
|
|
498
|
+
|
|
499
|
+
declared = "\n".join(
|
|
500
|
+
_read(path / archivo)
|
|
501
|
+
for archivo in ("pyproject.toml", "requirements.txt", "Pipfile", "poetry.lock", "uv.lock")
|
|
502
|
+
).lower()
|
|
503
|
+
if "fastapi" not in declared and "uvicorn" not in declared:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
module = _asgi_module(path)
|
|
507
|
+
if module is None:
|
|
508
|
+
return None
|
|
509
|
+
return _served(name, f"{prefix}uvicorn {module}:app --reload", path)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _asgi_module(root: Path) -> str | None:
|
|
513
|
+
"""Modulo con un `app` de nivel superior, en notacion de puntos."""
|
|
514
|
+
for candidate in ASGI_MODULES:
|
|
515
|
+
path = root / candidate
|
|
516
|
+
if not path.is_file():
|
|
517
|
+
continue
|
|
518
|
+
for line in _read(path).splitlines():
|
|
519
|
+
if line.startswith(("app = ", "app=", "app: ")):
|
|
520
|
+
return candidate[: -len(".py")].replace("/", ".")
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _go(root: Path) -> list[Service]:
|
|
525
|
+
"""Un servicio Go en la raiz, o en una subcarpeta de backend."""
|
|
526
|
+
return _backend_at(root, _go_at)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _go_at(path: Path, name: str) -> Service | None:
|
|
530
|
+
if not (path / "go.mod").is_file():
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
modulo = _read(path / "go.mod")
|
|
534
|
+
marco = any(server in modulo for server in GO_SERVERS)
|
|
535
|
+
|
|
536
|
+
# El paquete main, que ademas es lo que hay que pasarle a `go run`.
|
|
537
|
+
for candidato in GO_MAINS:
|
|
538
|
+
fuente = path / candidato
|
|
539
|
+
if not fuente.is_file():
|
|
540
|
+
continue
|
|
541
|
+
# net/http es stdlib: un servidor escrito con ella no aparece en go.mod,
|
|
542
|
+
# asi que la llamada en el fuente es la unica senal. Sin marco ni
|
|
543
|
+
# llamada es una herramienta de linea de comandos, y arrancarla se
|
|
544
|
+
# quedaria esperando un puerto que nunca abre.
|
|
545
|
+
if not marco and not any(s in _read(fuente) for s in GO_SERVES):
|
|
546
|
+
continue
|
|
547
|
+
objetivo = "." if candidato == "main.go" else f"./{candidato.rsplit('/', 1)[0]}"
|
|
548
|
+
return _served(name, f"go run {objetivo}", path)
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _rust(root: Path) -> list[Service]:
|
|
553
|
+
"""Un servicio Rust en la raiz, en una subcarpeta de backend, o en un Cargo workspace."""
|
|
554
|
+
# 1. Si la raiz misma es un servicio Rust ejecutable
|
|
555
|
+
at_root = _rust_at(root, "api")
|
|
556
|
+
if at_root is not None:
|
|
557
|
+
return [at_root]
|
|
558
|
+
|
|
559
|
+
# 2. Revisar si es un Cargo workspace ([workspace] en Cargo.toml)
|
|
560
|
+
workspace_members = _cargo_workspace_members(root)
|
|
561
|
+
if workspace_members:
|
|
562
|
+
found = []
|
|
563
|
+
for member_path in workspace_members:
|
|
564
|
+
service = _rust_at(member_path, member_path.name)
|
|
565
|
+
if service is not None:
|
|
566
|
+
found.append(service)
|
|
567
|
+
if found:
|
|
568
|
+
return found
|
|
569
|
+
|
|
570
|
+
# 3. Subcarpetas habituales de backend
|
|
571
|
+
found = []
|
|
572
|
+
for path in _subprojects(root, BACKEND_DIRS):
|
|
573
|
+
service = _rust_at(path, path.name)
|
|
574
|
+
if service is not None:
|
|
575
|
+
found.append(service)
|
|
576
|
+
return found
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _cargo_workspace_members(root: Path) -> list[Path]:
|
|
580
|
+
cargo_file = root / "Cargo.toml"
|
|
581
|
+
if not cargo_file.is_file():
|
|
582
|
+
return []
|
|
583
|
+
try:
|
|
584
|
+
data = tomllib.loads(_read(cargo_file))
|
|
585
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
586
|
+
return []
|
|
587
|
+
ws = data.get("workspace")
|
|
588
|
+
if not isinstance(ws, dict):
|
|
589
|
+
return []
|
|
590
|
+
members = ws.get("members", [])
|
|
591
|
+
if not isinstance(members, list):
|
|
592
|
+
return []
|
|
593
|
+
|
|
594
|
+
results = []
|
|
595
|
+
for pattern in members:
|
|
596
|
+
if not isinstance(pattern, str):
|
|
597
|
+
continue
|
|
598
|
+
# Soporta miembros directos ('crates/api') o globs simples ('crates/*')
|
|
599
|
+
if "*" in pattern:
|
|
600
|
+
results.extend(p for p in root.glob(pattern) if p.is_dir() and (p / "Cargo.toml").is_file())
|
|
601
|
+
else:
|
|
602
|
+
p = root / pattern
|
|
603
|
+
if p.is_dir() and (p / "Cargo.toml").is_file():
|
|
604
|
+
results.append(p)
|
|
605
|
+
return sorted(results, key=lambda p: p.name.lower())
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _rust_at(path: Path, name: str) -> Service | None:
|
|
609
|
+
cargo_path = path / "Cargo.toml"
|
|
610
|
+
if not cargo_path.is_file():
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
raw_cargo = _read(cargo_path)
|
|
614
|
+
try:
|
|
615
|
+
cargo_data = tomllib.loads(raw_cargo)
|
|
616
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
617
|
+
cargo_data = {}
|
|
618
|
+
|
|
619
|
+
# Si es explicitamente un workspace puro (tiene [workspace] pero no [package])
|
|
620
|
+
if "workspace" in cargo_data and "package" not in cargo_data and "bin" not in cargo_data:
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
# Determinar si tiene un binario ejecutable y como arrancarlo:
|
|
624
|
+
# 1. src/main.rs -> cargo run
|
|
625
|
+
# 2. [[bin]] en Cargo.toml -> cargo run --bin <name>
|
|
626
|
+
# 3. src/bin/<bin_name>.rs -> cargo run --bin <bin_name>
|
|
627
|
+
cmd = None
|
|
628
|
+
if (path / "src" / "main.rs").is_file():
|
|
629
|
+
cmd = "cargo run"
|
|
630
|
+
else:
|
|
631
|
+
# Revisar [[bin]] en Cargo.toml
|
|
632
|
+
bins = cargo_data.get("bin")
|
|
633
|
+
if isinstance(bins, list) and bins:
|
|
634
|
+
for b in bins:
|
|
635
|
+
if isinstance(b, dict) and b.get("name"):
|
|
636
|
+
cmd = f"cargo run --bin {b['name']}"
|
|
637
|
+
break
|
|
638
|
+
|
|
639
|
+
# Revisar src/bin/ si no hubo [[bin]] explicito
|
|
640
|
+
if not cmd:
|
|
641
|
+
bin_dir = path / "src" / "bin"
|
|
642
|
+
if bin_dir.is_dir():
|
|
643
|
+
for rs_file in sorted(bin_dir.glob("*.rs")):
|
|
644
|
+
cmd = f"cargo run --bin {rs_file.stem}"
|
|
645
|
+
break
|
|
646
|
+
|
|
647
|
+
if not cmd:
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
declared = raw_cargo.lower()
|
|
651
|
+
is_server = any(server in declared for server in RUST_SERVERS)
|
|
652
|
+
|
|
653
|
+
return Service(
|
|
654
|
+
name=name,
|
|
655
|
+
command=cmd,
|
|
656
|
+
cwd=path,
|
|
657
|
+
port=None,
|
|
658
|
+
ready="listen" if is_server else "none",
|
|
659
|
+
needs=(),
|
|
660
|
+
env={},
|
|
661
|
+
detached=False,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _ruby(root: Path) -> list[Service]:
|
|
666
|
+
"""Un Rails en la raiz, o en una subcarpeta de backend."""
|
|
667
|
+
return _backend_at(root, _ruby_at)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _ruby_at(path: Path, name: str) -> Service | None:
|
|
671
|
+
# config/application.rb solo existe en una aplicacion Rails: una gema o un
|
|
672
|
+
# engine tienen Gemfile y no lo tienen. Y una aplicacion Rails sirve por un
|
|
673
|
+
# puerto siempre, asi que no hace falta la pregunta que si necesitan Go y Node.
|
|
674
|
+
if not (path / "config" / "application.rb").is_file():
|
|
675
|
+
return None
|
|
676
|
+
if not (path / "Gemfile").is_file():
|
|
677
|
+
return None
|
|
678
|
+
# `bundle exec` y no el binstub `bin/rails`: es un script con shebang y en
|
|
679
|
+
# Windows no lo ejecuta nadie. Bundler ya es obligatorio si hay Gemfile.
|
|
680
|
+
return _served(name, "bundle exec rails server", path)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@functools.lru_cache(maxsize=None)
|
|
684
|
+
def _en_el_path(binario: str) -> bool:
|
|
685
|
+
"""Si el binario existe, cacheado.
|
|
686
|
+
|
|
687
|
+
`detect` corre en el camino de sondeo de la interfaz: `_project_view` lo
|
|
688
|
+
llama una vez por proyecto y por request, y la interfaz sondea cada 2.5s
|
|
689
|
+
por pestana abierta. `shutil.which` barre el PATH entero, y en Windows lo
|
|
690
|
+
permuta ademas contra cada extension de PATHEXT.
|
|
691
|
+
|
|
692
|
+
Medido en Windows con 59 directorios en el PATH: 13.5ms por llamada, contra
|
|
693
|
+
0.06ms cacheada. Con seis proyectos JVM y dos pestanas abiertas eso son
|
|
694
|
+
162ms de barrido de disco en cada `/api/state`, cada 2.5 segundos, sobre el
|
|
695
|
+
mismo threadpool que atiende apagar y matar procesos. Es el problema que
|
|
696
|
+
`server._docker_is_down` ya documenta del otro lado: sin su cache,
|
|
697
|
+
`/api/health` pasaba de 2ms a 9s.
|
|
698
|
+
|
|
699
|
+
ponytail: sin vencimiento, a diferencia del cache de Docker. Ahi el valor
|
|
700
|
+
cambia solo, porque el daemon se cae y se levanta; un binario del PATH no.
|
|
701
|
+
Instalar gradle con `serve` abierto pide reiniciar para que lo vea, y ese
|
|
702
|
+
caso no vale un cache con TTL y candado. Los tests lo limpian con
|
|
703
|
+
`cache_clear`, que si no el resultado cruza de un test al siguiente.
|
|
704
|
+
"""
|
|
705
|
+
return shutil.which(binario) is not None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _jvm(root: Path) -> list[Service]:
|
|
709
|
+
"""Un servicio JVM en la raiz, o en una subcarpeta de backend.
|
|
710
|
+
|
|
711
|
+
Java y Kotlin no son dos detectores: el build es el mismo y `.kts` solo
|
|
712
|
+
cambia la extension del archivo de Gradle.
|
|
713
|
+
"""
|
|
714
|
+
return _backend_at(root, _jvm_at)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _jvm_at(path: Path, name: str) -> Service | None:
|
|
718
|
+
if (path / "pom.xml").is_file():
|
|
719
|
+
build = _read(path / "pom.xml")
|
|
720
|
+
herramienta, wrapper = "mvn", ("./mvnw", "mvnw.cmd")
|
|
721
|
+
maven = True
|
|
722
|
+
else:
|
|
723
|
+
gradle = [path / f for f in ("build.gradle", "build.gradle.kts") if (path / f).is_file()]
|
|
724
|
+
if not gradle:
|
|
725
|
+
return None
|
|
726
|
+
# Los dos si estan los dos: un proyecto puede declarar los plugins en
|
|
727
|
+
# el Groovy y las dependencias en el Kotlin DSL.
|
|
728
|
+
build = "\n".join(_read(f) for f in gradle)
|
|
729
|
+
herramienta, wrapper = "gradle", ("./gradlew", "gradlew.bat")
|
|
730
|
+
maven = False
|
|
731
|
+
|
|
732
|
+
# Un `pom.xml` o un `build.gradle` a secas puede ser una libreria o una app
|
|
733
|
+
# de consola, y arrancarla dejaria al runner esperando un puerto que nunca
|
|
734
|
+
# abre. Hace falta el framework, igual que en Rust.
|
|
735
|
+
for marcas, goal, task in JVM_SERVERS:
|
|
736
|
+
tarea = goal if maven else task
|
|
737
|
+
if tarea is not None and any(marca in build for marca in marcas):
|
|
738
|
+
return _served(name, f"{_lanzador(path, herramienta, wrapper)} {tarea}", path)
|
|
739
|
+
return None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _lanzador(path: Path, herramienta: str, wrapper: tuple[str, str]) -> str:
|
|
743
|
+
"""Con que se invoca el build: el binario del PATH, o el wrapper del repo.
|
|
744
|
+
|
|
745
|
+
El binario gana cuando esta, y no es una preferencia de estilo. El comando
|
|
746
|
+
detectado termina en el `stack.yaml` que `freeze` escribe, y ese archivo se
|
|
747
|
+
commitea y lo abre alguien en otro sistema operativo. `mvn spring-boot:run`
|
|
748
|
+
es igual en los tres; el wrapper son dos archivos distintos (`./mvnw` no
|
|
749
|
+
corre en cmd.exe, `mvnw.cmd` no corre en bash), asi que congelar el wrapper
|
|
750
|
+
rompe el stack compartido de un equipo mixto.
|
|
751
|
+
|
|
752
|
+
Sin binario y sin wrapper se devuelve igual el nombre pelado: es un
|
|
753
|
+
proyecto que existe, y fallar con "command not found" dice mas que no
|
|
754
|
+
detectarlo. Es distinto del caso de la libreria, que no falla sino que se
|
|
755
|
+
cuelga esperando un puerto.
|
|
756
|
+
"""
|
|
757
|
+
if _en_el_path(herramienta):
|
|
758
|
+
return herramienta
|
|
759
|
+
elegido = wrapper[1] if os.name == "nt" else wrapper[0]
|
|
760
|
+
nombre = Path(elegido).name
|
|
761
|
+
if (path / nombre).is_file():
|
|
762
|
+
return elegido
|
|
763
|
+
if (path.parent / nombre).is_file():
|
|
764
|
+
return f"..\\{nombre}" if os.name == "nt" else f"../{nombre}"
|
|
765
|
+
return herramienta
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _elixir(root: Path) -> list[Service]:
|
|
769
|
+
"""Un Phoenix en la raiz, o en una subcarpeta de backend."""
|
|
770
|
+
return _backend_at(root, _elixir_at)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _elixir_at(path: Path, name: str) -> Service | None:
|
|
774
|
+
# `mix.exs` dice que hay un proyecto Elixir y nada mas: puede ser una
|
|
775
|
+
# libreria o una app OTP sin puerto, y arrancarla dejaria al runner
|
|
776
|
+
# esperando un socket que nunca abre. Hace falta la segunda senal.
|
|
777
|
+
if not (path / "mix.exs").is_file():
|
|
778
|
+
return None
|
|
779
|
+
|
|
780
|
+
# La dependencia, o la carpeta que Phoenix genera siempre. Dos senales
|
|
781
|
+
# porque una sola no alcanza: en un umbrella las dependencias viven en el
|
|
782
|
+
# mix.exs de la raiz y el hijo se queda sin la primera.
|
|
783
|
+
if PHOENIX_DEP.search(_read(path / "mix.exs")):
|
|
784
|
+
return _served(name, "mix phx.server", path)
|
|
785
|
+
|
|
786
|
+
# `iterdir` no se traga los errores como `glob`: sin `lib`, sin permisos o
|
|
787
|
+
# con la unidad desconectada, levanta. Y esto corre en el camino de sondeo
|
|
788
|
+
# de la interfaz, asi que una excepcion aca es un 500 cada 2.5 segundos.
|
|
789
|
+
# Es el mismo `try` que ya usa `_subprojects`.
|
|
790
|
+
try:
|
|
791
|
+
hijos = list((path / "lib").iterdir())
|
|
792
|
+
except OSError:
|
|
793
|
+
return None
|
|
794
|
+
if any(hijo.is_dir() and hijo.name.endswith("_web") for hijo in hijos):
|
|
795
|
+
return _served(name, "mix phx.server", path)
|
|
796
|
+
return None
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _php(root: Path) -> list[Service]:
|
|
800
|
+
"""Un Laravel en la raiz, o en una subcarpeta de backend."""
|
|
801
|
+
return _backend_at(root, _php_at)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _php_at(path: Path, name: str) -> Service | None:
|
|
805
|
+
# `artisan` en la raiz es Laravel y nada mas. Se pide ademas el
|
|
806
|
+
# composer.json, que es lo que hace que las dependencias esten instaladas.
|
|
807
|
+
if not (path / "artisan").is_file() or not (path / "composer.json").is_file():
|
|
808
|
+
return None
|
|
809
|
+
return _served(name, "php artisan serve", path)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _dotnet(root: Path) -> list[Service]:
|
|
813
|
+
"""Un ASP.NET Core en la raiz, o en una subcarpeta de backend."""
|
|
814
|
+
return _backend_at(root, _dotnet_at)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _dotnet_at(path: Path, name: str) -> Service | None:
|
|
818
|
+
# El atributo Sdk del csproj es lo unico que distingue una app web de una
|
|
819
|
+
# libreria o una consola, que usan `Microsoft.NET.Sdk` a secas. Ni el nombre
|
|
820
|
+
# del proyecto ni sus paquetes lo dicen.
|
|
821
|
+
proyectos = sorted(path.glob("*.csproj"))
|
|
822
|
+
for proyecto in proyectos:
|
|
823
|
+
if "microsoft.net.sdk.web" not in _read(proyecto).lower():
|
|
824
|
+
continue
|
|
825
|
+
# Con varios csproj en la misma carpeta, `dotnet run` no sabe cual y
|
|
826
|
+
# falla pidiendo que se lo digan.
|
|
827
|
+
objetivo = f' --project "{proyecto.name}"' if len(proyectos) > 1 else ""
|
|
828
|
+
return _served(name, f"dotnet watch run{objetivo}", path)
|
|
829
|
+
return None
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _backend_at(root: Path, detector) -> list[Service]:
|
|
833
|
+
"""La raiz si es el proyecto, y si no las subcarpetas de backend.
|
|
834
|
+
|
|
835
|
+
Es la forma que ya tenian `_python` y `_node`: si la raiz es el proyecto no
|
|
836
|
+
se baja una vuelta, porque el servicio se llamaria como la subcarpeta que no
|
|
837
|
+
existe.
|
|
838
|
+
"""
|
|
839
|
+
at_root = detector(root, "api")
|
|
840
|
+
if at_root is not None:
|
|
841
|
+
return [at_root]
|
|
842
|
+
found = []
|
|
843
|
+
for path in _subprojects(root, BACKEND_DIRS):
|
|
844
|
+
service = detector(path, path.name)
|
|
845
|
+
if service is not None:
|
|
846
|
+
found.append(service)
|
|
847
|
+
return found
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _web_or_backend_at(root: Path, detector) -> list[Service]:
|
|
851
|
+
"""La raiz si es el proyecto, y si no las subcarpetas de front o de back.
|
|
852
|
+
|
|
853
|
+
El analogo de `_backend_at` para los runtimes que sirven las dos cosas.
|
|
854
|
+
Deno y Bun corren igual un frontend que una API, asi que mirar solo
|
|
855
|
+
BACKEND_DIRS dejaria afuera un `frontend/` servido con cualquiera de los
|
|
856
|
+
dos. Se extrajo cuando aparecio el segundo uso, no antes.
|
|
857
|
+
"""
|
|
858
|
+
at_root = detector(root, "web")
|
|
859
|
+
if at_root is not None:
|
|
860
|
+
return [at_root]
|
|
861
|
+
found = []
|
|
862
|
+
for path in _subprojects(root, (*NODE_DIRS, *BACKEND_DIRS)):
|
|
863
|
+
service = detector(path, path.name)
|
|
864
|
+
if service is not None:
|
|
865
|
+
found.append(service)
|
|
866
|
+
return found
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _deno(root: Path) -> list[Service]:
|
|
870
|
+
return _web_or_backend_at(root, _deno_at)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _deno_at(path: Path, name: str) -> Service | None:
|
|
874
|
+
for f in ("deno.json", "deno.jsonc"):
|
|
875
|
+
deno_file = path / f
|
|
876
|
+
if deno_file.is_file():
|
|
877
|
+
try:
|
|
878
|
+
raw_text = _read(deno_file) or "{}"
|
|
879
|
+
clean_text = re.sub(r"(?m)^\s*//.*$|(?<=\s)//.*$", "", raw_text)
|
|
880
|
+
data = json.loads(clean_text)
|
|
881
|
+
if isinstance(data, dict):
|
|
882
|
+
tasks = data.get("tasks", {})
|
|
883
|
+
if isinstance(tasks, dict):
|
|
884
|
+
for task_name in ("dev", "start", "serve"):
|
|
885
|
+
if task_name in tasks:
|
|
886
|
+
return _served(name, f"deno task {task_name}", path)
|
|
887
|
+
except json.JSONDecodeError:
|
|
888
|
+
pass
|
|
889
|
+
if not (path / "package.json").is_file():
|
|
890
|
+
for entry in ("main.ts", "server.ts", "main.js", "server.js", "app.ts"):
|
|
891
|
+
if (path / entry).is_file():
|
|
892
|
+
return _served(name, f"deno run --allow-net {entry}", path)
|
|
893
|
+
return None
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _node(root: Path) -> list[Service]:
|
|
897
|
+
at_root = _package(root, root, "web", strict=False)
|
|
898
|
+
if at_root is not None:
|
|
899
|
+
# El package.json de la raiz manda: en un monorepo su script `dev` suele
|
|
900
|
+
# ser el orquestador (turbo, nx) y arrancar ademas los hijos duplicaria
|
|
901
|
+
# todo. Si no hay ninguno, se busca una vuelta mas abajo.
|
|
902
|
+
return [at_root]
|
|
903
|
+
|
|
904
|
+
found = []
|
|
905
|
+
for path in _subprojects(root, NODE_DIRS):
|
|
906
|
+
service = _package(path, root, path.name, strict=True)
|
|
907
|
+
if service is not None:
|
|
908
|
+
found.append(service)
|
|
909
|
+
return found
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _bun(root: Path) -> list[Service]:
|
|
913
|
+
"""Bun como runtime, lo que `_node` deja pasar.
|
|
914
|
+
|
|
915
|
+
Va **despues** de `_node` en la tupla de `detect`, y esa posicion es la
|
|
916
|
+
mitad de la logica. Un proyecto con `package.json` y `bun.lock` ya salia
|
|
917
|
+
bien de antes: `_package` lee el script y `_manager` devuelve `bun` por el
|
|
918
|
+
lockfile. Adelantar `_bun` le robaria el nombre del servicio y lo arrancaria
|
|
919
|
+
con el archivo en vez del script.
|
|
920
|
+
|
|
921
|
+
Lo que queda para aca es el proyecto sin `package.json`, sin `scripts`, o
|
|
922
|
+
con scripts que no sirven nada: ahi Bun corre el archivo directo.
|
|
923
|
+
"""
|
|
924
|
+
return _web_or_backend_at(root, _bun_at)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _bun_at(path: Path, name: str) -> Service | None:
|
|
928
|
+
if not any((base / marca).is_file() for base in (path, path.parent) for marca in BUN_MARKERS):
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
raw = json.loads(_read(path / "package.json") or "{}")
|
|
933
|
+
except json.JSONDecodeError:
|
|
934
|
+
raw = {}
|
|
935
|
+
declaradas = {
|
|
936
|
+
*(raw.get("dependencies") or {}),
|
|
937
|
+
*(raw.get("devDependencies") or {}),
|
|
938
|
+
} if isinstance(raw, dict) else set()
|
|
939
|
+
marco = any(dep.startswith(server) for dep in declaradas for server in BUN_SERVERS)
|
|
940
|
+
|
|
941
|
+
for candidato in BUN_ENTRIES:
|
|
942
|
+
fuente = path / candidato
|
|
943
|
+
if not fuente.is_file():
|
|
944
|
+
continue
|
|
945
|
+
# `Bun.serve` es la API nativa, o `export default { fetch }`, o framework HTTP.
|
|
946
|
+
# Sin ninguna de las tres es una CLI, y arrancarla dejaria al runner
|
|
947
|
+
# esperando un puerto que nunca abre.
|
|
948
|
+
contenido = _read(fuente)
|
|
949
|
+
es_servidor = (
|
|
950
|
+
marco
|
|
951
|
+
or (BUN_SERVES in contenido)
|
|
952
|
+
or ("export default" in contenido and "fetch" in contenido)
|
|
953
|
+
)
|
|
954
|
+
if not es_servidor:
|
|
955
|
+
continue
|
|
956
|
+
return _served(name, f"bun run {candidato}", path)
|
|
957
|
+
return None
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _subprojects(root: Path, names: tuple[str, ...]):
|
|
961
|
+
"""Subcarpetas candidatas, una sola vuelta.
|
|
962
|
+
|
|
963
|
+
Sin recursion a proposito: un scan profundo entra en `node_modules` y en cada
|
|
964
|
+
template de ejemplo que tenga el repo.
|
|
965
|
+
"""
|
|
966
|
+
for name in names:
|
|
967
|
+
yield root / name
|
|
968
|
+
for group in WORKSPACE_DIRS:
|
|
969
|
+
parent = root / group
|
|
970
|
+
if not parent.is_dir():
|
|
971
|
+
continue
|
|
972
|
+
try:
|
|
973
|
+
children = sorted(parent.iterdir(), key=lambda p: p.name.lower())
|
|
974
|
+
except OSError:
|
|
975
|
+
continue
|
|
976
|
+
for child in children:
|
|
977
|
+
if child.is_dir() and not child.name.startswith(".") and child.name != "node_modules":
|
|
978
|
+
yield child
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _package(path: Path, root: Path, name: str, strict: bool) -> Service | None:
|
|
982
|
+
try:
|
|
983
|
+
raw = json.loads(_read(path / "package.json") or "{}")
|
|
984
|
+
except json.JSONDecodeError:
|
|
985
|
+
return None
|
|
986
|
+
if not isinstance(raw, dict):
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
scripts = raw.get("scripts")
|
|
990
|
+
if not isinstance(scripts, dict):
|
|
991
|
+
return None
|
|
992
|
+
script = next((s for s in NODE_SCRIPTS if isinstance(scripts.get(s), str)), None)
|
|
993
|
+
if script is None:
|
|
994
|
+
return None
|
|
995
|
+
if strict and not _serves(raw):
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
return _served(name, f"{_manager(raw, path, root)} run {script}", path)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _serves(raw: dict) -> bool:
|
|
1002
|
+
"""Si el paquete declara un servidor de desarrollo.
|
|
1003
|
+
|
|
1004
|
+
En un workspace hay tantas librerias como apps, y una libreria con
|
|
1005
|
+
`dev: tsc --watch` entraria como servicio y nunca abriria un puerto: el
|
|
1006
|
+
arranque se quedaria esperando a que este lista hasta el timeout.
|
|
1007
|
+
"""
|
|
1008
|
+
declared = {
|
|
1009
|
+
*(raw.get("dependencies") or {}),
|
|
1010
|
+
*(raw.get("devDependencies") or {}),
|
|
1011
|
+
}
|
|
1012
|
+
return any(
|
|
1013
|
+
dep == server or dep.startswith(server) for dep in declared for server in DEV_SERVERS
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _manager(raw: dict, path: Path, root: Path) -> str:
|
|
1018
|
+
pinned = raw.get("packageManager")
|
|
1019
|
+
if isinstance(pinned, str):
|
|
1020
|
+
for tool in ("pnpm", "yarn", "bun", "npm"):
|
|
1021
|
+
if pinned.startswith(tool):
|
|
1022
|
+
return tool
|
|
1023
|
+
# El lockfile del propio paquete, y si no el de la raiz: en un monorepo hay
|
|
1024
|
+
# uno solo y esta arriba.
|
|
1025
|
+
for base in (path, root):
|
|
1026
|
+
for lock, tool in LOCKFILES.items():
|
|
1027
|
+
if (base / lock).is_file():
|
|
1028
|
+
return tool
|
|
1029
|
+
return "npm"
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# helpers ------------------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _served(name: str, command: str, root: Path) -> Service:
|
|
1036
|
+
"""Servicio de larga duracion cuyo puerto se descubre al arrancar."""
|
|
1037
|
+
return Service(
|
|
1038
|
+
name=name,
|
|
1039
|
+
command=command,
|
|
1040
|
+
cwd=root,
|
|
1041
|
+
port=None,
|
|
1042
|
+
ready="listen",
|
|
1043
|
+
needs=(),
|
|
1044
|
+
env={},
|
|
1045
|
+
detached=False,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _read(path: Path) -> str:
|
|
1050
|
+
try:
|
|
1051
|
+
return path.read_text(encoding="utf-8")
|
|
1052
|
+
except (OSError, UnicodeDecodeError):
|
|
1053
|
+
return ""
|