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/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 ""