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/__init__.py
ADDED
stackhelx/__main__.py
ADDED
stackhelx/browse.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Exploracion de carpetas para el selector de proyectos de la interfaz.
|
|
2
|
+
|
|
3
|
+
El navegador no puede dar rutas absolutas (webkitdirectory las oculta a
|
|
4
|
+
proposito), asi que el listado sale de aca. Solo nombres de carpetas y de los
|
|
5
|
+
archivos marcadores: nunca contenido, nunca archivos sueltos.
|
|
6
|
+
|
|
7
|
+
Recorrer el disco no tiene nada que ver con servir HTTP, y este modulo no
|
|
8
|
+
importa nada de fastapi: `server` traduce el ValueError a un 400.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import psutil
|
|
17
|
+
|
|
18
|
+
from . import detect
|
|
19
|
+
|
|
20
|
+
MARKERS = (
|
|
21
|
+
"stack.yaml", "stack.yml", *detect.COMPOSE_NAMES,
|
|
22
|
+
"package.json", "manage.py", "Cargo.toml",
|
|
23
|
+
"bunfig.toml", "mix.exs",
|
|
24
|
+
"pom.xml", "build.gradle", "build.gradle.kts",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Carpetas que nunca son un proyecto y solo hacen ruido al navegar.
|
|
28
|
+
SKIP = {"node_modules", "__pycache__", "venv", "env", "dist", "build", "target"}
|
|
29
|
+
LIMIT = 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def listing(raw: str) -> dict:
|
|
33
|
+
"""Contenido navegable de `raw`, o las raices si viene vacio.
|
|
34
|
+
|
|
35
|
+
Levanta ValueError con un motivo legible si la ruta no sirve.
|
|
36
|
+
"""
|
|
37
|
+
if not raw:
|
|
38
|
+
return roots()
|
|
39
|
+
|
|
40
|
+
path = Path(raw).expanduser()
|
|
41
|
+
if not path.is_absolute():
|
|
42
|
+
raise ValueError(f"la ruta debe ser absoluta: {raw}")
|
|
43
|
+
try:
|
|
44
|
+
path = path.resolve(strict=True)
|
|
45
|
+
except OSError:
|
|
46
|
+
raise ValueError(f"no existe: {raw}") from None
|
|
47
|
+
if not path.is_dir():
|
|
48
|
+
raise ValueError(f"no es un directorio: {path}")
|
|
49
|
+
|
|
50
|
+
names = _subdirs(path)
|
|
51
|
+
return {
|
|
52
|
+
"path": str(path),
|
|
53
|
+
# "" manda a la vista de raices; None es no tener a donde subir.
|
|
54
|
+
"parent": "" if path.parent == path else str(path.parent),
|
|
55
|
+
"markers": markers(path),
|
|
56
|
+
"truncated": len(names) > LIMIT,
|
|
57
|
+
"entries": [
|
|
58
|
+
{"name": name, "path": str(path / name), "markers": markers(path / name)}
|
|
59
|
+
for name in names[:LIMIT]
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _subdirs(path: Path) -> list[str]:
|
|
65
|
+
found = []
|
|
66
|
+
try:
|
|
67
|
+
with os.scandir(path) as items:
|
|
68
|
+
for item in items:
|
|
69
|
+
if item.name.startswith(".") or item.name in SKIP:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
if item.is_dir():
|
|
73
|
+
found.append(item.name)
|
|
74
|
+
except OSError:
|
|
75
|
+
continue # enlace roto o unidad desconectada
|
|
76
|
+
except OSError:
|
|
77
|
+
return [] # sin permisos se ve vacia, que no es un error del usuario
|
|
78
|
+
return sorted(found, key=str.lower)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _es_archivo(entry: os.DirEntry) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
return entry.is_file()
|
|
84
|
+
except OSError:
|
|
85
|
+
return False # enlace roto o unidad desconectada
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def markers(path: Path) -> list[str]:
|
|
89
|
+
"""Archivos que delatan un proyecto.
|
|
90
|
+
|
|
91
|
+
Un `scandir` por carpeta y cruce de nombres, y no un `is_file()` por
|
|
92
|
+
marcador, que era el techo que este mismo comentario dejaba anotado: el
|
|
93
|
+
costo estaba atado al largo de `MARKERS`, y sumar lenguajes lo empuja.
|
|
94
|
+
Ahora no crece con la lista, solo con lo que la carpeta tiene de verdad.
|
|
95
|
+
|
|
96
|
+
Se pregunta por la entrada solo cuando el nombre ya coincide con un
|
|
97
|
+
marcador. Sin ese filtro, una carpeta con mil archivos pagaria mil
|
|
98
|
+
preguntas y el cambio seria una regresion en vez de una mejora.
|
|
99
|
+
|
|
100
|
+
**Sin distinguir mayusculas, y no es un detalle.** `(path / "Cargo.toml")
|
|
101
|
+
.is_file()` daba True con un `cargo.toml` en disco, porque el sistema de
|
|
102
|
+
archivos lo resolvia: NTFS y el APFS por defecto de macOS no distinguen.
|
|
103
|
+
Comparar nombres exactos habria borrado el badge en dos de las tres
|
|
104
|
+
plataformas de la CI, en silencio y sin ningun test en rojo. Se compara
|
|
105
|
+
igual en las tres, que ademas es una respuesta y no una casualidad del
|
|
106
|
+
sistema de archivos donde toco correr.
|
|
107
|
+
|
|
108
|
+
El conjunto se arma en cada llamada a proposito: `MARKERS` se puede
|
|
109
|
+
parchear (los tests lo hacen), y un conjunto de modulo quedaria viejo. Es
|
|
110
|
+
un punado de cadenas en memoria contra una llamada al sistema, no se nota.
|
|
111
|
+
"""
|
|
112
|
+
objetivo = {name.lower(): name for name in MARKERS}
|
|
113
|
+
try:
|
|
114
|
+
with os.scandir(path) as items:
|
|
115
|
+
presentes = {
|
|
116
|
+
item.name.lower()
|
|
117
|
+
for item in items
|
|
118
|
+
if item.name.lower() in objetivo and _es_archivo(item)
|
|
119
|
+
}
|
|
120
|
+
except OSError:
|
|
121
|
+
return [] # sin permisos o no existe: se ve sin badges, no es un error
|
|
122
|
+
return [name for clave, name in objetivo.items() if clave in presentes]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def roots() -> dict:
|
|
126
|
+
"""Punto de partida: la home del usuario y las unidades montadas."""
|
|
127
|
+
seen = {str(Path.home())}
|
|
128
|
+
entries = [{"name": "Inicio", "path": str(Path.home()), "markers": []}]
|
|
129
|
+
try:
|
|
130
|
+
partitions = psutil.disk_partitions(all=False)
|
|
131
|
+
except OSError:
|
|
132
|
+
partitions = []
|
|
133
|
+
for partition in partitions:
|
|
134
|
+
mount = partition.mountpoint
|
|
135
|
+
if mount not in seen and os.path.isdir(mount):
|
|
136
|
+
seen.add(mount)
|
|
137
|
+
entries.append({"name": mount, "path": mount, "markers": []})
|
|
138
|
+
return {"path": "", "parent": None, "markers": [], "truncated": False, "entries": entries}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def frequent_roots(project_paths: list[Path]) -> list[str]:
|
|
142
|
+
"""Calcula las carpetas padre mas comunes de los proyectos registrados.
|
|
143
|
+
|
|
144
|
+
Permite saltos directos en el selector de carpetas y autocompletado sin
|
|
145
|
+
recorrer todo el arbol de discos.
|
|
146
|
+
"""
|
|
147
|
+
counts: dict[str, int] = {}
|
|
148
|
+
for p in project_paths:
|
|
149
|
+
try:
|
|
150
|
+
parent = p.parent
|
|
151
|
+
if parent != p and parent.is_dir():
|
|
152
|
+
parent_str = str(parent)
|
|
153
|
+
counts[parent_str] = counts.get(parent_str, 0) + 1
|
|
154
|
+
except OSError:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# Ordenar por frecuencia descendente
|
|
158
|
+
sorted_parents = [k for k, _ in sorted(counts.items(), key=lambda item: item[1], reverse=True)]
|
|
159
|
+
return sorted_parents[:6]
|
|
160
|
+
|